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

183 lines
19 KiB
TypeScript

import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { resetSandboxGit, sandboxGitState } from '../src/main/sandbox/git-repository'
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
import { restoreTransaction } from '../src/main/sandbox/transactions'
import { sandboxOperation } from '../src/main/sandbox/maintenance'
if (process.platform !== 'win32') throw new Error('Git regression requires real Windows isolation')
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-'))
const workspace = join(fixture, 'workspace space \u4e2d\u6587'), stateRoot = join(fixture, 'state'), home = join(fixture, 'host-home')
await fs.mkdir(workspace); await fs.mkdir(home)
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64'), useBundledGit: false })
const source = process.env.YCODE_TEST_GIT_SOURCE ?? 'C:\\Program Files\\Git\\cmd\\git.exe'
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)))
Object.assign(environment, { HOME: home, USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' })
const hostGit = (...args: string[]): string => execFileSync(source, args, { cwd: workspace, env: environment, windowsHide: true, encoding: 'utf8', timeout: 15_000 })
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}`) }
let approvals = 0
const context = { workspaceRoot: workspace, sessionId: 'git-session', runId: 'git-run', toolCallId: 'git-call', runtimes: ['git'], approve: async () => { approvals++; return true } }
async function run(command: string, overrides: Partial<typeof context> = {}) {
const result = await executeSandboxCommand(command, { ...context, ...overrides })
await fs.writeFile(join(fixture, 'last-result.json'), JSON.stringify(result, null, 2) + '\n')
return result
}
async function inventory(root: string): Promise<Record<string, string>> {
const result: Record<string, string> = {}
async function walk(path: string, prefix = ''): Promise<void> {
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
const name = `${prefix}${entry.name}`
if (entry.isDirectory()) await walk(join(path, entry.name), `${name}/`)
else result[name] = createHash('sha256').update(await fs.readFile(join(path, entry.name))).digest('hex')
}
}
await walk(root); return result
}
try {
await fs.writeFile(join(workspace, 'tracked.txt'), 'host committed\n')
await fs.writeFile(join(workspace, '.env'), 'DUMMY_HISTORICAL_SECRET_NOT_FOR_SANDBOX')
hostGit('init', '-q', '--template=', '--initial-branch=host-branch')
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'add', '--all')
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-q', '-m', 'HOST_HISTORY_MUST_NOT_BE_IMPORTED')
await fs.unlink(join(workspace, '.env'))
await fs.writeFile(join(workspace, 'tracked.txt'), 'current filtered snapshot\n')
hostGit('add', 'tracked.txt')
hostGit('config', 'credential.helper', '!echo HOST_CREDENTIAL_HELPER_MUST_NOT_RUN')
hostGit('config', 'core.fsmonitor', '!echo HOST_FSMONITOR_MUST_NOT_RUN')
const originalGit = await inventory(join(workspace, '.git'))
await fs.mkdir(join(workspace, 'nested space'))
await fs.writeFile(join(workspace, 'nested space/\u8def\u5f84.txt'), 'unicode baseline\n')
await fs.writeFile(join(workspace, 'binary.dat'), Buffer.from([0, 1, 255, 0, 32]))
await fs.writeFile(join(workspace, 'hook-script'), '#!/bin/sh\nexit 73\n')
await fs.writeFile(join(workspace, '.gitattributes'), 'crlf.txt text eol=lf\nencoded.txt working-tree-encoding=UTF-16LE filter=host-filter\n')
await fs.writeFile(join(workspace, 'crlf.txt'), 'raw CRLF baseline\r\n')
await fs.writeFile(join(workspace, 'encoded.txt'), 'raw snapshot bytes\n')
check('Git starts missing without inheriting the host installation', (await listSandboxToolchains()).find(item => item.kind === 'git')?.state === 'missing')
await assert.rejects(run('git --version'), /尚未注册/)
check('missing Git runtime fails before native launch', true)
const runtime = await importSandboxToolchain('git', source)
check('explicit Git import selects the x64 image and its DLL closure', runtime.state === 'registered' && runtime.features?.includes('git-local') && runtime.files! >= 7)
const isolatedImage = join(fixture, 'missing-dll/git.exe')
await fs.mkdir(join(fixture, 'missing-dll'))
await fs.copyFile(join(stateRoot, 'toolchains/objects', runtime.id!, 'files/git.exe'), isolatedImage)
await assert.rejects(importSandboxToolchain('git', isolatedImage), /ENOENT/)
check('an incomplete import preserves the previously registered Git runtime', (await listSandboxToolchains()).find(item => item.kind === 'git')?.id === runtime.id)
check('registered Git capability states the private repository boundary', (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'implemented' && item.detail.includes('沙盒专用')))
let result = await run('git --version && git status --porcelain=v1 && git diff --exit-code && git diff --cached --exit-code && git branch --show-current && git log -1 --oneline')
check('real isolated Git runs with a synthetic filtered baseline', result.ok && result.data.stdout.includes('git version') && result.data.stdout.includes('ycode-sandbox') && result.data.stdout.includes('filtered workspace snapshot') && !result.data.stdout.includes('HOST_HISTORY'))
check('read-only Git does not ask for approval or change the repository revision', approvals === 0 && result.data.git?.initialized === true && result.data.git.persistence === 'unchanged')
check('result and content explicitly distinguish sandbox state from host Git state', result.data.git?.mode === 'private-snapshot' && result.content.includes('不含宿主历史/索引') && result.data.toolchains?.[0].id === runtime.id)
const baseline = await sandboxGitState(workspace)
result = await run('git show "HEAD:nested space/\u8def\u5f84.txt" && git fsck --no-reflogs')
check('synthetic trees/index support nested Unicode names, spaces and binary objects', result.ok && result.data.stdout.includes('unicode baseline'))
result = await run('git diff --exit-code -- crlf.txt encoded.txt && git show HEAD:crlf.txt && git show HEAD:encoded.txt')
check('raw-byte baseline remains clean with CRLF and project encoding/filter attributes', result.ok && result.data.stdout.includes('raw CRLF baseline') && result.data.stdout.includes('raw snapshot bytes') && result.data.git?.persistence === 'unchanged')
result = await run('git status --porcelain=v1 && git log --all -p && git config --get-regexp "credential|fsmonitor|hooksPath"')
check('host history, deleted credentials and host configuration are absent', result.ok && !result.data.stdout.includes('DUMMY_HISTORICAL_SECRET') && !result.data.stdout.includes('HOST_CREDENTIAL') && !result.data.stdout.includes('HOST_FSMONITOR'))
check('repeated status/log preserves the exact private revision without approvals', (await sandboxGitState(workspace)).id === baseline.id && approvals === 0)
await fs.writeFile(join(workspace, 'tracked.txt'), 'edited outside after sandbox baseline\n')
result = await run('git status --short && git diff -- tracked.txt')
check('status and diff observe external worktree edits against the private baseline', result.ok && result.data.stdout.includes(' M tracked.txt') && result.data.stdout.includes('+edited outside after sandbox baseline') && approvals === 0)
result = await run('git add tracked.txt && git diff --cached --stat')
check('staging is approval-bound and persists independently of worktree writeback', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'unchanged' && approvals === 1)
result = await run('git diff --cached -- tracked.txt && git commit -q -m local-sandbox-commit')
check('a later command sees the staged index and creates a local commit', result.ok && result.data.stdout.includes('+edited outside after sandbox baseline') && result.data.git?.persistence === 'saved' && approvals === 2)
check('completed Git persistence can be queried from the durable run outcome', (await sandboxOperation(result.data.runId)).git?.revision === result.data.git?.revision)
result = await run('git log -2 --oneline && git status --porcelain=v1 && git fsck --no-reflogs')
check('private commits and valid objects survive command boundaries', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.stdout.includes('filtered workspace snapshot') && result.data.git?.persistence === 'unchanged')
await importSandboxToolchain('node', await fs.realpath(process.execPath))
result = await run('node --require "%YCODE_SANDBOX_PROCESS%" -e "console.log(require(\'child_process\').execFileSync(\'git\',[\'log\',\'-1\',\'--oneline\'],{encoding:\'utf8\'}))"', { runtimes: ['node', 'git'] })
check('a controlled Node subprocess inherits the private Git runtime and repository', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.git?.persistence === 'unchanged')
const beforeReject = await sandboxGitState(workspace)
result = await run('git branch rejected-branch', { approve: async () => false })
check('rejected metadata-only approval leaves the active revision unchanged', !result.ok && result.data.git?.persistence === 'rejected' && (await sandboxGitState(workspace)).id === beforeReject.id)
result = await run('git branch --list rejected-branch')
check('a rejected branch does not appear in later commands', result.ok && !result.data.stdout.trim())
const beforeFailure = await sandboxGitState(workspace)
result = await run('git branch failed-command-branch && exit /b 7')
check('failed commands discard private metadata changes', !result.ok && result.data.exitCode === 7 && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
result = await executeSandboxCommand('git branch timed-out-branch && echo GIT_MUTATION_BEFORE_TIMEOUT && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, timeoutMs: 1200 })
check('a real native timeout discards already-created private branch metadata', !result.ok && result.data.status === 'timeout' && result.data.stdout.includes('GIT_MUTATION_BEFORE_TIMEOUT') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
const controller = new AbortController(), cancellation = setTimeout(() => controller.abort(), 2500)
try { result = await executeSandboxCommand('git branch cancelled-branch && echo GIT_MUTATION_BEFORE_CANCEL && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, signal: controller.signal }) }
finally { clearTimeout(cancellation) }
check('cancellation discards private mutations and releases the repository lock', !result.ok && result.data.status === 'cancelled' && result.data.stdout.includes('GIT_MUTATION_BEFORE_CANCEL') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
result = await run('git switch -c feature-local && echo sandbox-file>created.txt && git add created.txt && git commit -q -m approved-file-and-commit')
check('approved worktree changes and private branch/commit are both saved', result.ok && result.data.writeback === 'committed' && result.data.git?.persistence === 'saved' && (await fs.readFile(join(workspace, 'created.txt'), 'utf8')).trim() === 'sandbox-file')
check('file writeback retains an independently recoverable transaction', !!result.data.transactionId && !!result.data.authorizationId)
await restoreTransaction(result.data.transactionId!, workspace)
result = await run('git status --short && git branch --show-current')
check('file recovery becomes a visible worktree deletion without rewriting private history', result.ok && result.data.stdout.includes(' D created.txt') && result.data.stdout.includes('feature-local'))
const beforeConflict = await sandboxGitState(workspace)
await assert.rejects(run('git branch concurrent-conflict', { approve: async () => { await fs.appendFile(join(workspace, 'tracked.txt'), 'parallel edit\n'); return true } }), /Git.*期间变化/)
check('an edit during metadata approval rejects stale private state', (await sandboxGitState(workspace)).id === beforeConflict.id)
result = await run('mkdir "%GIT_DIR%\\hooks" && copy /y hook-script "%GIT_DIR%\\hooks\\pre-commit" >nul && git config core.hooksPath "%GIT_DIR%\\hooks" && git config core.fsmonitor "echo BAD_FSMONITOR" && git config credential.helper "echo BAD_CREDENTIAL" && git commit --allow-empty -q -m hooks-disabled')
check('default hooks and fsmonitor remain disabled even after private repo config changes', result.ok && result.data.git?.persistence === 'saved')
result = await run('git config --get core.fsmonitor && git config --get core.hooksPath && git ls-remote https://example.invalid/test.git')
check('remote transport is denied without prompting for host credentials', !result.ok && /not allowed|not a git command|transport/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_CREDENTIAL'))
const outside = join(fixture, 'outside.txt')
await fs.writeFile(outside, 'HOST_OUTSIDE_MUST_NOT_BE_READ')
result = await run(`git hash-object "${outside}"`)
check('native Git cannot read an ordinary file outside its snapshot', !result.ok && /denied|not permitted/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_OUTSIDE'))
result = await run('powershell.exe -NoProfile -NonInteractive -Command "try {[IO.File]::WriteAllText((Join-Path $env:YCODE_SANDBOX_ROOT \'tools/git/git.exe\'),\'bad\'); exit 9} catch {[Console]::WriteLine(\'GIT_RUNTIME_READONLY\')}"')
check('AppContainer ACLs deny modification of the staged Git image', result.ok && result.data.stdout.includes('GIT_RUNTIME_READONLY'))
const otherWorkspace = join(fixture, 'other-workspace'); await fs.mkdir(otherWorkspace)
await fs.writeFile(join(otherWorkspace, '.git'), 'gitdir: C:/host-repository-must-not-be-opened\n')
result = await run('git log --all --oneline', { workspaceRoot: otherWorkspace })
check('another workspace receives a separate baseline and ignores host worktree gitfiles', result.ok && result.data.git?.initialized && !result.data.stdout.includes('local-sandbox-commit') && !result.data.stdout.includes('hooks-disabled'))
await assert.rejects(importSandboxDependencies(workspace, 'git'), /node\/python/)
check('Git runtime is not accepted as a Node/Python dependency-cache kind', true)
const workspaceKey = createHash('sha256').update((await fs.realpath(workspace)).toLowerCase()).digest('hex')
const privateRoot = join(stateRoot, 'git', workspaceKey)
const active = JSON.parse(await fs.readFile(join(privateRoot, 'active.json'), 'utf8'))
const privateFile = join(privateRoot, 'objects', active.id, 'files/HEAD'), privateBytes = await fs.readFile(privateFile)
await fs.appendFile(privateFile, 'tampered')
await assert.rejects(run('git status'), /metadata digest mismatch/)
check('tampered saved metadata fails closed before execution', true)
await fs.writeFile(privateFile, privateBytes)
const hardlinkTarget = join(fixture, 'git-hardlink.txt'); await fs.writeFile(hardlinkTarget, privateBytes)
await fs.unlink(privateFile); await fs.link(hardlinkTarget, privateFile)
await assert.rejects(run('git status'), /metadata contains a link/)
check('saved metadata hardlinks are rejected even when content digests match', true)
await fs.unlink(privateFile); await fs.writeFile(privateFile, privateBytes)
const runtimeIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
const runtimeFile = join(stateRoot, 'toolchains/objects', runtimeIdentity.id, 'files/git.exe'), runtimeBytes = await fs.readFile(runtimeFile)
await fs.appendFile(runtimeFile, 'tampered')
await assert.rejects(run('git status'), /摘要不匹配/)
check('tampered Git runtime fails before any metadata or worktree access', true)
await fs.writeFile(runtimeFile, runtimeBytes)
await assert.rejects(run('if not exist "%GIT_DIR%\\objects\\info" mkdir "%GIT_DIR%\\objects\\info"\r\necho C:/outside>"%GIT_DIR%\\objects\\info\\alternates"'), /unsafe sandbox Git metadata path/)
check('external object alternates cannot be persisted', true)
await assert.rejects(run('echo stale>"%GIT_DIR%\\index.lock"'), /unsafe sandbox Git metadata path/)
check('incomplete Git lock files are rejected instead of poisoning the next command', true)
const policy = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['tracked.txt'] }, workspaceRoot: workspace })
await assert.rejects(run('git show HEAD:tracked.txt'), /policy changed/)
check('newly denied paths cannot leak through an older private history', true)
await resetSandboxGit(workspace)
result = await run('git ls-files && git log -1 --oneline')
check('explicit reset creates a new filtered baseline after policy changes', result.ok && result.data.git?.initialized && !result.data.stdout.includes('tracked.txt') && result.data.stdout.includes('filtered workspace snapshot'))
check('reset archives earlier revisions without mutating host Git', (await fs.readdir(privateRoot)).some(name => name.startsWith('archived-')) && JSON.stringify(await inventory(join(workspace, '.git'))) === JSON.stringify(originalGit))
check('Git metadata remains outside the host worktree and policy retains network deny', !(await fs.stat(join(workspace, 'repository')).catch(() => null)) && (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('tracked.txt'))
const denyPolicy = await readSandboxPolicy()
await updateSandboxPolicy({ expectedRevision: denyPolicy.revision, rules: { writeback: 'deny' }, workspaceRoot: otherWorkspace })
await resetSandboxGit(otherWorkspace)
result = await run('git status --short', { workspaceRoot: otherWorkspace })
const denyBaseline = await sandboxGitState(otherWorkspace)
await assert.rejects(run('git branch forbidden-by-policy', { workspaceRoot: otherWorkspace }), /禁止写回/)
check('writeback-deny policy also refuses private repository publication', result.ok && (await sandboxGitState(otherWorkspace)).id === denyBaseline.id)
console.log(`SANDBOX_GIT_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, scope: 'Real AppContainer Git for Windows and private filtered repository. No host Git history/index writeback or remote transport is supported.' }, null, 2) + '\n')
console.log(`SANDBOX_GIT_REPORT ${join(fixture, 'report.json')}`)
}