feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -0,0 +1,236 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { SandboxToolchainIdentity, SandboxToolchainKind, SandboxToolchainView } from '@shared/sandbox'
|
||||
import { sandboxConfiguration } from './configuration'
|
||||
import { assertActive, protectedPath, within } from './policy'
|
||||
import { gitRuntimeExecutable, gitRuntimeImports, gitSystemDll } from './git-runtime'
|
||||
|
||||
interface ToolchainFile { path: string; bytes: number; sha256: string }
|
||||
interface ToolchainManifest { version: 1; kind: SandboxToolchainKind; id: string; createdAt: number; files: ToolchainFile[]; bytes: number; features: string[]; distribution?: { version: string; archiveSha256: string; releaseUrl: string } }
|
||||
interface LocatedToolchain { manifest: ToolchainManifest; directory: string; identity: SandboxToolchainIdentity; source: 'bundled' | 'imported' }
|
||||
const kinds: SandboxToolchainKind[] = ['node', 'python', 'git']
|
||||
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const ceilings = { files: 12_000, bytes: 512 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 4 * 1024 * 1024 }
|
||||
const hash = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex')
|
||||
const root = (): string => join(sandboxConfiguration().stateRoot, 'toolchains')
|
||||
function kindValue(value: string): SandboxToolchainKind {
|
||||
if (!kinds.includes(value as SandboxToolchainKind)) throw new Error('当前受控运行时只支持 node/python/git')
|
||||
return value as SandboxToolchainKind
|
||||
}
|
||||
function filePath(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || /[:\x00-\x1f]/.test(value)) throw new Error('运行时文件路径无效')
|
||||
const path = value.replaceAll('\\', '/')
|
||||
const publicCertificate = path === 'Lib/site-packages/pip/_vendor/certifi/cacert.pem'
|
||||
if (path.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(path) && !publicCertificate) throw new Error(`运行时文件包含受保护路径或链接目标:${path}`)
|
||||
return path
|
||||
}
|
||||
async function regularBytes(path: string, ceiling: number, allowSourceHardlink = false): Promise<Buffer> {
|
||||
const stat = await fs.lstat(path)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || !allowSourceHardlink && stat.nlink !== 1 || stat.size > ceiling) throw new Error('运行时文件类型或大小无效')
|
||||
const handle = await fs.open(path, 'r')
|
||||
try {
|
||||
const before = await handle.stat()
|
||||
if (before.ino !== stat.ino || before.dev !== stat.dev || !before.isFile() || before.size > ceiling) throw new Error('运行时文件读取前发生替换')
|
||||
const bytes = await handle.readFile(), after = await handle.stat()
|
||||
if (bytes.length !== before.size || bytes.length > ceiling || after.mtimeMs !== before.mtimeMs || after.size !== before.size) throw new Error('运行时文件读取期间发生变化')
|
||||
return bytes
|
||||
} finally { await handle.close() }
|
||||
}
|
||||
async function nonLinkDirectory(path: string): Promise<void> {
|
||||
const stat = await fs.lstat(path)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('运行时目录不能是链接')
|
||||
}
|
||||
async function manifestFor(kind: SandboxToolchainKind): Promise<LocatedToolchain | null> {
|
||||
const alias = join(root(), `${kind}.json`)
|
||||
const stat = await fs.lstat(alias).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
|
||||
if (!stat) return kind === 'git' && sandboxConfiguration().useBundledGit !== false ? bundledGit() : null
|
||||
await nonLinkDirectory(root())
|
||||
const active = JSON.parse((await regularBytes(alias, 4096)).toString('utf8')) as SandboxToolchainIdentity
|
||||
if (active.kind !== kind || !uuid.test(active.id) || !hashPattern.test(active.manifestSha256)) throw new Error('运行时引用无效')
|
||||
const directory = join(root(), 'objects', active.id)
|
||||
await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files'))
|
||||
const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes)
|
||||
if (hash(bytes) !== active.manifestSha256) throw new Error('运行时清单摘要不匹配')
|
||||
const manifest = validateManifest(bytes, active)
|
||||
return { manifest, directory, identity: active, source: 'imported' }
|
||||
}
|
||||
function validateManifest(bytes: Buffer, active: SandboxToolchainIdentity): ToolchainManifest {
|
||||
const kind = active.kind, manifest = JSON.parse(bytes.toString('utf8')) as ToolchainManifest
|
||||
if (manifest.version !== 1 || manifest.id !== active.id || manifest.kind !== kind || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > ceilings.files
|
||||
|| !Array.isArray(manifest.features) || manifest.features.some(feature => typeof feature !== 'string' || feature.length > 64)) throw new Error('运行时清单格式无效')
|
||||
const seen = new Set<string>(); let total = 0
|
||||
for (const file of manifest.files) {
|
||||
const path = filePath(file.path), key = path.toLowerCase()
|
||||
if (seen.has(key) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > ceilings.fileBytes || !hashPattern.test(file.sha256)) throw new Error('运行时清单文件身份无效')
|
||||
seen.add(key); total += file.bytes
|
||||
}
|
||||
if (manifest.bytes !== total || total > ceilings.bytes || !seen.has(`${kind}.exe`)) throw new Error('运行时文件清单或容量无效')
|
||||
return manifest
|
||||
}
|
||||
async function bundledGit(): Promise<LocatedToolchain | null> {
|
||||
const resources = sandboxConfiguration().resourceDirectory
|
||||
const componentPath = join(resources, 'manifest.json')
|
||||
if (!await fs.lstat(componentPath).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null
|
||||
await nonLinkDirectory(resources)
|
||||
const component = JSON.parse((await regularBytes(componentPath, ceilings.manifestBytes)).toString('utf8')) as { bundledGit?: { manifestSha256?: string; version?: string } }
|
||||
if (!component.bundledGit) return null
|
||||
if (!hashPattern.test(component.bundledGit.manifestSha256 ?? '') || typeof component.bundledGit.version !== 'string') throw new Error('内置 Git 引用无效;请重新构建或安装应用')
|
||||
const directory = join(resources, 'git')
|
||||
await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files'))
|
||||
const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes)
|
||||
if (hash(bytes) !== component.bundledGit.manifestSha256) throw new Error('内置 Git 清单摘要不匹配;请重新构建或安装应用')
|
||||
const raw = JSON.parse(bytes.toString('utf8')) as ToolchainManifest
|
||||
if (!uuid.test(raw.id)) throw new Error('内置 Git 身份无效')
|
||||
const identity: SandboxToolchainIdentity = { kind: 'git', id: raw.id, manifestSha256: hash(bytes) }
|
||||
const manifest = validateManifest(bytes, identity)
|
||||
if (manifest.distribution?.version !== component.bundledGit.version || !hashPattern.test(manifest.distribution.archiveSha256)) throw new Error('内置 Git 发行信息无效')
|
||||
return { manifest, directory, identity, source: 'bundled' }
|
||||
}
|
||||
export async function listSandboxToolchains(): Promise<SandboxToolchainView[]> {
|
||||
return Promise.all(kinds.map(async kind => {
|
||||
try {
|
||||
const value = await manifestFor(kind)
|
||||
return value ? { kind, state: 'registered' as const, id: value.manifest.id, files: value.manifest.files.length, bytes: value.manifest.bytes, features: value.manifest.features, source: value.source, ...(value.manifest.distribution ? { version: value.manifest.distribution.version } : {}) }
|
||||
: { kind, state: 'missing' as const, reason: kind === 'git' ? '内置 Git 不可用;请修复应用资源,或由维护者导入替代版本' : '尚未注册受控运行时;不会继承宿主 PATH' }
|
||||
} catch (error) { return { kind, state: 'invalid' as const, reason: error instanceof Error ? error.message : String(error) } }
|
||||
}))
|
||||
}
|
||||
export async function sandboxToolchainIdentity(kind: SandboxToolchainKind): Promise<SandboxToolchainIdentity | null> {
|
||||
return (await manifestFor(kind))?.identity ?? null
|
||||
}
|
||||
/** Import explicit local runtime bytes. The source interpreter is never executed on the host. */
|
||||
export async function importSandboxToolchain(kindInput: string, executableInput: string): Promise<SandboxToolchainView> {
|
||||
const kind = kindValue(kindInput)
|
||||
if (!isAbsolute(executableInput)) throw new Error('运行时源程序必须是显式绝对路径')
|
||||
const selected = await fs.realpath(executableInput)
|
||||
if (basename(selected).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配')
|
||||
const executable = kind === 'git' ? await gitRuntimeExecutable(selected) : selected
|
||||
if (basename(executable).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配')
|
||||
const sourceRoot = dirname(executable), id = randomUUID(), directory = join(root(), 'objects', id), filesRoot = join(directory, 'files')
|
||||
await fs.mkdir(filesRoot, { recursive: true, mode: 0o700 })
|
||||
await nonLinkDirectory(root()); await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(filesRoot)
|
||||
const manifest: ToolchainManifest = { version: 1, kind, id, createdAt: Date.now(), files: [], bytes: 0, features: [kind] }
|
||||
async function put(destination: string, bytes: Buffer): Promise<void> {
|
||||
const path = filePath(destination)
|
||||
if (manifest.files.length >= ceilings.files || bytes.length > ceilings.fileBytes || manifest.bytes + bytes.length > ceilings.bytes) throw new Error('运行时超过受控导入容量上限')
|
||||
const target = join(filesRoot, path)
|
||||
await fs.mkdir(dirname(target), { recursive: true })
|
||||
await fs.writeFile(target, bytes, { flag: 'wx', mode: 0o600 })
|
||||
manifest.files.push({ path, bytes: bytes.length, sha256: hash(bytes) }); manifest.bytes += bytes.length
|
||||
}
|
||||
async function copyFile(source: string, destination: string): Promise<Buffer> {
|
||||
if (!within(sourceRoot, source)) throw new Error('运行时导入源越界')
|
||||
const bytes = await regularBytes(source, ceilings.fileBytes, true)
|
||||
await put(destination, bytes)
|
||||
return bytes
|
||||
}
|
||||
async function copyTree(source: string, destination: string, depth = 0): Promise<void> {
|
||||
if (depth > 64) throw new Error('运行时目录层级过深')
|
||||
await nonLinkDirectory(source)
|
||||
for await (const entry of await fs.opendir(source)) {
|
||||
if (entry.name === '__pycache__' || entry.name === '.git' || entry.name === 'site-packages' || kind === 'python' && ['test', 'tests', 'idlelib'].includes(entry.name)) continue
|
||||
const path = join(source, entry.name), target = `${destination}/${entry.name}`
|
||||
if (protectedPath(target) && target !== 'Lib/site-packages/pip/_vendor/certifi/cacert.pem') continue
|
||||
if (entry.isSymbolicLink()) throw new Error('运行时源包含符号链接')
|
||||
if (entry.isDirectory()) await copyTree(path, target, depth + 1)
|
||||
else await copyFile(path, target)
|
||||
}
|
||||
}
|
||||
const executableBytes = await copyFile(executable, `${kind}.exe`)
|
||||
if (kind === 'node') {
|
||||
const npm = join(sourceRoot, 'node_modules/npm')
|
||||
if (await fs.stat(npm).catch(() => null)) {
|
||||
const pkg = JSON.parse((await regularBytes(join(npm, 'package.json'), 1024 * 1024)).toString('utf8'))
|
||||
if (pkg.name !== 'npm') throw new Error('npm 源目录身份无效')
|
||||
await copyTree(npm, 'node_modules/npm')
|
||||
await put('npm.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npm-cli.js" %*\r\n'))
|
||||
await put('npx.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npx-cli.js" %*\r\n'))
|
||||
manifest.features.push('npm', 'npx')
|
||||
}
|
||||
} else if (kind === 'python') {
|
||||
for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) {
|
||||
if (entry.isFile() && /^(?:python3\d*|vcruntime\d+(?:_\d+)?)\.dll$/i.test(entry.name)) await copyFile(join(sourceRoot, entry.name), entry.name)
|
||||
}
|
||||
if (!manifest.files.some(file => /^python3\d+\.dll$/i.test(file.path))) throw new Error('Python 源目录缺少版本 DLL')
|
||||
await copyTree(join(sourceRoot, 'Lib'), 'Lib')
|
||||
if (await fs.stat(join(sourceRoot, 'DLLs')).catch(() => null)) await copyTree(join(sourceRoot, 'DLLs'), 'DLLs')
|
||||
const pip = join(sourceRoot, 'Lib/site-packages/pip')
|
||||
if (await fs.stat(pip).catch(() => null)) { await copyTree(pip, 'Lib/site-packages/pip'); manifest.features.push('pip') }
|
||||
} else {
|
||||
await nonLinkDirectory(sourceRoot)
|
||||
const seen = new Set<string>(['git.exe']), pending = [{ bytes: executableBytes, directory: sourceRoot }]
|
||||
const addExecutable = async (path: string, name: string): Promise<boolean> => {
|
||||
if (!await fs.lstat(path).catch(() => null)) return false
|
||||
const bytes = await regularBytes(path, ceilings.fileBytes, true)
|
||||
gitRuntimeImports(bytes)
|
||||
await put(name, bytes); seen.add(name.toLowerCase()); pending.push({ bytes, directory: dirname(path) })
|
||||
return true
|
||||
}
|
||||
const http = await addExecutable(join(sourceRoot, 'git-remote-http.exe'), 'git-remote-http.exe')
|
||||
const https = await addExecutable(join(sourceRoot, 'git-remote-https.exe'), 'git-remote-https.exe')
|
||||
for (const name of ['git-upload-pack.exe', 'git-receive-pack.exe']) await addExecutable(join(sourceRoot, name), name)
|
||||
const usr = join(dirname(dirname(sourceRoot)), 'usr/bin')
|
||||
const shell = await addExecutable(join(usr, 'sh.exe'), 'sh.exe')
|
||||
const ssh = await addExecutable(join(usr, 'ssh.exe'), 'ssh.exe')
|
||||
if (http && https) {
|
||||
const curl = await regularBytes(join(sourceRoot, 'libcurl-4.dll'), ceilings.fileBytes, true)
|
||||
await put('libcurl-4.dll', curl); seen.add('libcurl-4.dll'); pending.push({ bytes: curl, directory: sourceRoot })
|
||||
}
|
||||
while (pending.length) {
|
||||
const image = pending.shift()!
|
||||
for (const dll of gitRuntimeImports(image.bytes)) {
|
||||
if (gitSystemDll(dll) || seen.has(dll)) continue
|
||||
seen.add(dll)
|
||||
if (seen.size > 128) throw new Error('Git DLL dependency closure exceeds the import limit')
|
||||
await nonLinkDirectory(image.directory)
|
||||
const dependency = await regularBytes(join(image.directory, dll), ceilings.fileBytes, true)
|
||||
await put(dll, dependency)
|
||||
pending.push({ bytes: dependency, directory: image.directory })
|
||||
}
|
||||
}
|
||||
await put('empty-hooks/.keep', Buffer.alloc(0))
|
||||
await put('empty-template/.keep', Buffer.alloc(0))
|
||||
manifest.features.push('git-local', 'private-snapshot-repository')
|
||||
if (http && https) manifest.features.push('git-http')
|
||||
if (shell && ssh) manifest.features.push('git-ssh')
|
||||
}
|
||||
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
|
||||
if (bytes.length > ceilings.manifestBytes) throw new Error('运行时清单过大')
|
||||
await fs.writeFile(join(directory, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 })
|
||||
const identity: SandboxToolchainIdentity = { kind, id, manifestSha256: hash(bytes) }, temporary = join(root(), `${kind}-${id}.tmp`)
|
||||
try {
|
||||
await fs.writeFile(temporary, JSON.stringify(identity) + '\n', { flag: 'wx', mode: 0o600 })
|
||||
await fs.rename(temporary, join(root(), `${kind}.json`))
|
||||
} finally { await fs.unlink(temporary).catch(() => undefined) }
|
||||
return { kind, state: 'registered', id, files: manifest.files.length, bytes: manifest.bytes, features: manifest.features, source: 'imported' }
|
||||
}
|
||||
export async function stageSandboxToolchains(stage: string, requested: string[], signal?: AbortSignal): Promise<SandboxToolchainIdentity[]> {
|
||||
if (!Array.isArray(requested) || requested.length > kinds.length || new Set(requested).size !== requested.length) throw new Error('沙盒运行时选择无效')
|
||||
const identities: SandboxToolchainIdentity[] = []
|
||||
for (const input of requested) {
|
||||
assertActive(signal)
|
||||
const kind = kindValue(input), value = await manifestFor(kind)
|
||||
if (!value) throw new Error(`受控运行时 ${kind} 尚未注册;请用 sandbox:maintain toolchain-add 显式导入`)
|
||||
const source = join(value.directory, 'files'), target = join(stage, 'tools', kind)
|
||||
const checkedDirectories = new Set<string>([source])
|
||||
for (const file of value.manifest.files) {
|
||||
assertActive(signal)
|
||||
const path = join(source, file.path)
|
||||
let parent = dirname(path)
|
||||
while (!checkedDirectories.has(parent)) {
|
||||
if (!within(source, parent)) throw new Error('运行时目录越界')
|
||||
await nonLinkDirectory(parent); checkedDirectories.add(parent); parent = dirname(parent)
|
||||
}
|
||||
const bytes = await regularBytes(path, ceilings.fileBytes)
|
||||
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error(`受控运行时文件摘要不匹配:${kind}/${file.path}`)
|
||||
const destination = resolve(target, file.path)
|
||||
if (!within(target, destination)) throw new Error('运行时目标越界')
|
||||
await fs.mkdir(dirname(destination), { recursive: true })
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
identities.push(value.identity)
|
||||
}
|
||||
return identities
|
||||
}
|
||||
Reference in new issue
Block a user