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,145 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { dirname, join, relative, resolve, isAbsolute } from 'node:path'
|
||||
import type { SandboxDependencyIdentity, SandboxDependencyKind, SandboxToolchainIdentity } from '@shared/sandbox'
|
||||
import { sandboxConfiguration } from './configuration'
|
||||
import { assertActive, protectedPath, resolveSandboxPath, within } from './policy'
|
||||
import { effectiveSandboxPolicy, sandboxPathDenied } from './policy-store'
|
||||
import { sandboxToolchainIdentity } from './toolchains'
|
||||
|
||||
interface DependencyFile { path: string; bytes: number; sha256: string }
|
||||
interface DependencyManifest { version: 1; workspaceRoot: string; identity: SandboxDependencyIdentity; files: DependencyFile[]; bytes: number; excluded: number }
|
||||
const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex')
|
||||
const rootKey = (root: string): string => hash(process.platform === 'win32' ? root.toLowerCase() : root)
|
||||
const limits = { files: 30_000, bytes: 512 * 1024 * 1024, fileBytes: 32 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 }
|
||||
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
function kindValue(kind: string): SandboxDependencyKind {
|
||||
if (kind !== 'node' && kind !== 'python') throw new Error('依赖缓存只支持 node/python')
|
||||
return kind
|
||||
}
|
||||
function validPath(path: string): boolean { return !!path && !isAbsolute(path) && !path.split(/[\\/]/).some(part => !part || part === '.' || part === '..') && !protectedPath(path) }
|
||||
async function regular(path: string, maximum = limits.fileBytes): Promise<Buffer> {
|
||||
const stat = await fs.lstat(path)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error('依赖文件是链接、特殊文件或超过上限')
|
||||
const handle = await fs.open(path, 'r')
|
||||
try {
|
||||
const opened = await handle.stat()
|
||||
if (opened.ino !== stat.ino || opened.dev !== stat.dev || opened.nlink !== 1 || opened.size !== stat.size) throw new Error('依赖文件读取前发生替换')
|
||||
const bytes = await handle.readFile(), after = await handle.stat()
|
||||
if (bytes.length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error('依赖文件读取期间发生变化')
|
||||
return bytes
|
||||
} finally { await handle.close() }
|
||||
}
|
||||
async function directory(path: string): Promise<void> {
|
||||
const stat = await fs.lstat(path)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('依赖缓存目录不能是链接')
|
||||
}
|
||||
export async function dependencyLockHash(workspace: string, kind: SandboxDependencyKind): Promise<string> {
|
||||
kindValue(kind)
|
||||
const names = kind === 'node' ? ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']
|
||||
: ['pyproject.toml', 'requirements.txt', 'requirements.lock', 'poetry.lock', 'uv.lock']
|
||||
const values = []
|
||||
for (const name of names) {
|
||||
const path = await resolveSandboxPath(workspace, name)
|
||||
const exists = await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
|
||||
values.push([name, exists ? hash(await regular(path, 8 * 1024 * 1024)) : null])
|
||||
}
|
||||
return hash(JSON.stringify(values))
|
||||
}
|
||||
function cacheRoot(workspace: string): string { return join(sandboxConfiguration().stateRoot, 'dependencies', rootKey(workspace)) }
|
||||
/** Only explicit workspace-local dependency roots are imported; no arbitrary model-supplied path is opened. */
|
||||
export async function importSandboxDependencies(workspaceInput: string, kindInput: string): Promise<{ identity: SandboxDependencyIdentity; files: number; bytes: number; excluded: number }> {
|
||||
const kind = kindValue(kindInput), workspace = await fs.realpath(workspaceInput)
|
||||
const policy = await effectiveSandboxPolicy(workspace)
|
||||
const source = await resolveSandboxPath(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages')
|
||||
if (sandboxPathDenied(policy, source)) throw new Error('沙盒策略禁止导入该依赖路径')
|
||||
if (within(source, resolve(sandboxConfiguration().stateRoot))) throw new Error('依赖源目录不能包含沙盒状态目录')
|
||||
await directory(source)
|
||||
const runtime = await sandboxToolchainIdentity(kind)
|
||||
if (!runtime) throw new Error('请先注册对应运行时再导入依赖')
|
||||
const id = randomUUID(), parent = cacheRoot(workspace), object = join(parent, 'objects', id), target = join(object, 'files')
|
||||
await fs.mkdir(target, { recursive: true, mode: 0o700 })
|
||||
for (const path of [join(sandboxConfiguration().stateRoot, 'dependencies'), parent, join(parent, 'objects'), object, target]) await directory(path)
|
||||
const identity: SandboxDependencyIdentity = { kind, id, lockHash: await dependencyLockHash(workspace, kind), manifestSha256: '', runtimeId: runtime.id, runtimeManifestSha256: runtime.manifestSha256 }
|
||||
const manifest: DependencyManifest = { version: 1, workspaceRoot: workspace, identity, files: [], bytes: 0, excluded: 0 }
|
||||
async function walk(path: string, depth = 0): Promise<void> {
|
||||
if (depth > 64) throw new Error('依赖目录层级过深')
|
||||
await directory(path)
|
||||
for await (const entry of await fs.opendir(path)) {
|
||||
if (entry.name === '__pycache__') { manifest.excluded++; continue }
|
||||
const absolute = join(path, entry.name), file = relative(source, absolute).replaceAll('\\', '/')
|
||||
if (protectedPath(file)) { manifest.excluded++; continue }
|
||||
if (sandboxPathDenied(policy, absolute)) throw new Error('依赖文件被工作区策略禁止')
|
||||
await resolveSandboxPath(workspace, absolute)
|
||||
if (entry.isSymbolicLink()) throw new Error('依赖缓存不接受链接;请先生成实际文件布局')
|
||||
if (entry.isDirectory()) await walk(absolute, depth + 1)
|
||||
else {
|
||||
if (!validPath(file) || manifest.files.length >= limits.files) throw new Error('依赖路径或总项数无效')
|
||||
const bytes = await regular(absolute)
|
||||
manifest.bytes += bytes.length
|
||||
if (manifest.bytes > limits.bytes) throw new Error('依赖超过 512 MiB 缓存上限')
|
||||
await fs.mkdir(dirname(join(target, file)), { recursive: true })
|
||||
await fs.writeFile(join(target, file), bytes, { flag: 'wx', mode: 0o600 })
|
||||
manifest.files.push({ path: file, bytes: bytes.length, sha256: hash(bytes) })
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(source)
|
||||
if (await dependencyLockHash(workspace, kind) !== identity.lockHash) throw new Error('依赖导入期间项目锁文件变化')
|
||||
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
|
||||
if (bytes.length > limits.manifestBytes) throw new Error('依赖清单超过上限')
|
||||
await fs.writeFile(join(object, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 })
|
||||
const active: SandboxDependencyIdentity = { ...identity, manifestSha256: hash(bytes) }, temporary = join(parent, `${kind}-${id}.tmp`)
|
||||
try {
|
||||
await fs.writeFile(temporary, JSON.stringify(active) + '\n', { flag: 'wx', mode: 0o600 })
|
||||
await fs.rename(temporary, join(parent, `${kind}.json`))
|
||||
} finally { await fs.unlink(temporary).catch(() => undefined) }
|
||||
return { identity: active, files: manifest.files.length, bytes: manifest.bytes, excluded: manifest.excluded }
|
||||
}
|
||||
export async function stageSandboxDependencies(workspace: string, stage: string, requested: string[], runtimes: SandboxToolchainIdentity[], signal?: AbortSignal): Promise<SandboxDependencyIdentity[]> {
|
||||
if (!Array.isArray(requested) || requested.length > 2 || new Set(requested).size !== requested.length) throw new Error('依赖缓存选择无效')
|
||||
const identities: SandboxDependencyIdentity[] = []
|
||||
const policy = await effectiveSandboxPolicy(workspace)
|
||||
for (const name of requested) {
|
||||
assertActive(signal)
|
||||
const kind = kindValue(name), parent = cacheRoot(workspace)
|
||||
await directory(join(sandboxConfiguration().stateRoot, 'dependencies')); await directory(parent)
|
||||
const active = JSON.parse((await regular(join(parent, `${kind}.json`), 4096)).toString('utf8')) as SandboxDependencyIdentity
|
||||
if (active.kind !== kind || !uuid.test(active.id) || !/^[a-f0-9]{64}$/.test(active.manifestSha256) || !/^[a-f0-9]{64}$/.test(active.lockHash)) throw new Error('依赖缓存身份无效')
|
||||
const runtime = runtimes.find(item => item.kind === kind)
|
||||
if (!runtime || runtime.id !== active.runtimeId || runtime.manifestSha256 !== active.runtimeManifestSha256) throw new Error('依赖缓存与所选运行时身份不匹配,请选择运行时或重新导入依赖')
|
||||
if (await dependencyLockHash(join(stage, 'workspace'), kind) !== active.lockHash) throw new Error('项目配置或锁文件已变化,请重新导入依赖缓存')
|
||||
const object = join(parent, 'objects', active.id), source = join(object, 'files')
|
||||
await directory(join(parent, 'objects')); await directory(object); await directory(source)
|
||||
const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes)
|
||||
if (hash(bytes) !== active.manifestSha256) throw new Error('依赖清单摘要不匹配')
|
||||
const manifest = JSON.parse(bytes.toString('utf8')) as DependencyManifest
|
||||
if (manifest.version !== 1 || rootKey(manifest.workspaceRoot) !== rootKey(workspace) || manifest.identity.id !== active.id || manifest.identity.kind !== kind || manifest.identity.lockHash !== active.lockHash || manifest.identity.runtimeId !== active.runtimeId || manifest.identity.runtimeManifestSha256 !== active.runtimeManifestSha256
|
||||
|| !Array.isArray(manifest.files) || manifest.files.length > limits.files || !Number.isSafeInteger(manifest.bytes) || manifest.bytes > limits.bytes) throw new Error('依赖清单归属或容量无效')
|
||||
const target = kind === 'node' ? join(stage, 'workspace/node_modules') : join(stage, 'home/python-site')
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
let total = 0; const seen = new Set<string>(), directories = new Set<string>([source])
|
||||
for (const file of manifest.files) {
|
||||
assertActive(signal)
|
||||
if (!validPath(file.path) || seen.has(file.path.toLowerCase()) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error('依赖清单条目无效')
|
||||
if (sandboxPathDenied(policy, join(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages', file.path))) throw new Error('依赖文件被当前工作区策略禁止')
|
||||
seen.add(file.path.toLowerCase())
|
||||
const path = resolve(source, file.path)
|
||||
if (!within(source, path)) throw new Error('依赖文件越界')
|
||||
let ancestor = dirname(path)
|
||||
while (!directories.has(ancestor)) {
|
||||
if (!within(source, ancestor)) throw new Error('依赖父目录越界')
|
||||
await directory(ancestor); directories.add(ancestor); ancestor = dirname(ancestor)
|
||||
}
|
||||
const data = await regular(path)
|
||||
if (data.length !== file.bytes || hash(data) !== file.sha256) throw new Error('依赖文件摘要不匹配')
|
||||
total += data.length
|
||||
if (total > limits.bytes) throw new Error('依赖总容量超过上限')
|
||||
await fs.mkdir(dirname(join(target, file.path)), { recursive: true })
|
||||
await fs.writeFile(join(target, file.path), data, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
if (total !== manifest.bytes) throw new Error('依赖清单总容量不匹配')
|
||||
identities.push(active)
|
||||
}
|
||||
return identities
|
||||
}
|
||||
Reference in new issue
Block a user