312 lines
22 KiB
TypeScript
312 lines
22 KiB
TypeScript
import { promises as fs } from 'node:fs'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { deflate } from 'node:zlib'
|
|
import { promisify } from 'node:util'
|
|
import { dirname, isAbsolute, join, relative } from 'node:path'
|
|
import type { SandboxEffectivePolicy, SandboxGitState } from '@shared/sandbox'
|
|
import { sandboxConfiguration } from './configuration'
|
|
import { assertActive, protectedPath, within } from './policy'
|
|
import { assertPolicyCurrent } from './policy-store'
|
|
import { sealWorkspace } from './workspace'
|
|
|
|
interface GitFile { path: string; bytes: number; sha256: string }
|
|
export interface GitIdentity { id: string; manifestSha256: string }
|
|
export interface GitManifest {
|
|
version: 1; id: string; workspaceRoot: string; policyFingerprint: string
|
|
createdAt: number; contentHash: string; files: GitFile[]; bytes: number
|
|
origin?: 'host' | 'remote' | 'empty'
|
|
}
|
|
export interface SandboxGitSession {
|
|
state: SandboxGitState
|
|
capture(signal?: AbortSignal): Promise<GitIdentity | null>
|
|
assertCurrent(): Promise<void>
|
|
publish(candidate: GitIdentity): Promise<void>
|
|
close(): Promise<void>
|
|
}
|
|
const limits = { files: 30_000, bytes: 256 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 }
|
|
const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex')
|
|
const sha1 = (bytes: Buffer): Buffer => createHash('sha1').update(bytes).digest()
|
|
const compress = promisify(deflate)
|
|
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 key = (value: string): string => process.platform === 'win32' ? value.toLowerCase() : value
|
|
const repositoryRoot = (workspace: string, namespace?: string): string => {
|
|
if (namespace !== undefined && !hashPattern.test(namespace)) throw new Error('Invalid sandbox Git draft namespace')
|
|
return join(sandboxConfiguration().stateRoot, 'git', hash(key(workspace)) + (namespace ? `-${namespace}` : ''))
|
|
}
|
|
|
|
async function directory(path: string): Promise<void> {
|
|
const stat = await fs.lstat(path)
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Sandbox Git state directory must not be a link')
|
|
}
|
|
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('Sandbox Git metadata contains a link, special file or oversized file')
|
|
const handle = await fs.open(path, 'r')
|
|
try {
|
|
const before = await handle.stat()
|
|
if (before.ino !== stat.ino || before.dev !== stat.dev || before.nlink !== 1 || before.size !== stat.size) throw new Error('Sandbox Git file was replaced before reading')
|
|
const bytes = await handle.readFile(), after = await handle.stat()
|
|
if (bytes.length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw new Error('Sandbox Git file changed during reading')
|
|
return bytes
|
|
} finally { await handle.close() }
|
|
}
|
|
function filePath(value: unknown): string {
|
|
if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || value.includes('\\') || value.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(value)
|
|
|| /(?:^|\/)(?:commondir|gitdir|alternates|http-alternates)$/.test(value) || /\.lock$/i.test(value)) throw new Error('Unsupported or unsafe sandbox Git metadata path')
|
|
return value
|
|
}
|
|
async function prepareRoot(workspace: string, namespace?: string): Promise<string> {
|
|
const root = repositoryRoot(workspace, namespace)
|
|
await fs.mkdir(join(root, 'objects'), { recursive: true, mode: 0o700 })
|
|
for (const path of [sandboxConfiguration().stateRoot, join(sandboxConfiguration().stateRoot, 'git'), root, join(root, 'objects')]) await directory(path)
|
|
return root
|
|
}
|
|
async function activeIdentity(root: string): Promise<GitIdentity | null> {
|
|
const path = join(root, 'active.json')
|
|
if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null
|
|
const value = JSON.parse((await regular(path, 4096)).toString('utf8')) as GitIdentity
|
|
if (!uuid.test(value.id) || !hashPattern.test(value.manifestSha256)) throw new Error('Invalid sandbox Git revision identity')
|
|
return value
|
|
}
|
|
async function manifestAt(root: string, identity: GitIdentity, workspace: string): Promise<GitManifest> {
|
|
const object = join(root, 'objects', identity.id)
|
|
await directory(object); await directory(join(object, 'files'))
|
|
const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes)
|
|
if (hash(bytes) !== identity.manifestSha256) throw new Error('Sandbox Git manifest digest mismatch')
|
|
const value = JSON.parse(bytes.toString('utf8')) as GitManifest
|
|
if (value.version !== 1 || value.id !== identity.id || typeof value.workspaceRoot !== 'string' || key(value.workspaceRoot) !== key(workspace) || !hashPattern.test(value.policyFingerprint)
|
|
|| value.origin !== undefined && !['host', 'remote', 'empty'].includes(value.origin)
|
|
|| !Array.isArray(value.files) || !value.files.length || value.files.length > limits.files) throw new Error('Invalid sandbox Git manifest ownership or format')
|
|
let total = 0
|
|
const seen = new Set<string>()
|
|
for (const file of value.files) {
|
|
const path = filePath(file.path)
|
|
if (seen.has(key(path)) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !hashPattern.test(file.sha256)) throw new Error('Invalid sandbox Git file identity')
|
|
seen.add(key(path)); total += file.bytes
|
|
}
|
|
if (!seen.has(key('HEAD')) || !seen.has('config') || value.bytes !== total || total > limits.bytes || value.contentHash !== hash(JSON.stringify(value.files))) throw new Error('Invalid sandbox Git metadata inventory')
|
|
return value
|
|
}
|
|
async function scanRepository(source: string, signal?: AbortSignal): Promise<{ files: GitFile[]; bytes: number }> {
|
|
const files: GitFile[] = []
|
|
let bytes = 0, entries = 0
|
|
async function walk(path: string, depth = 0): Promise<void> {
|
|
if (depth > 64) throw new Error('Sandbox Git metadata nesting exceeds the limit')
|
|
await directory(path)
|
|
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
|
|
assertActive(signal)
|
|
if (++entries > limits.files) throw new Error('Sandbox Git metadata exceeds the entry limit')
|
|
const absolute = join(path, entry.name), file = filePath(relative(source, absolute).replaceAll('\\', '/'))
|
|
if (entry.isSymbolicLink()) throw new Error('Sandbox Git metadata cannot contain links')
|
|
if (entry.isDirectory()) await walk(absolute, depth + 1)
|
|
else {
|
|
const content = await regular(absolute)
|
|
bytes += content.length
|
|
if (bytes > limits.bytes) throw new Error('Sandbox Git history exceeds the 256 MiB limit')
|
|
files.push({ path: file, bytes: content.length, sha256: hash(content) })
|
|
}
|
|
}
|
|
}
|
|
await walk(source)
|
|
files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
|
|
return { files, bytes }
|
|
}
|
|
async function storeRepository(root: string, source: string, policy: SandboxEffectivePolicy, inventory: { files: GitFile[]; bytes: number }, signal?: AbortSignal, origin?: 'host' | 'remote' | 'empty'): Promise<GitIdentity> {
|
|
const id = randomUUID(), object = join(root, 'objects', id), target = join(object, 'files')
|
|
await fs.mkdir(target, { recursive: true, mode: 0o700 })
|
|
const manifest: GitManifest = { version: 1, id, workspaceRoot: policy.workspaceRoot, policyFingerprint: policy.fingerprint, createdAt: Date.now(), contentHash: hash(JSON.stringify(inventory.files)), ...(origin ? { origin } : {}), ...inventory }
|
|
for (const file of inventory.files) {
|
|
assertActive(signal)
|
|
const content = await regular(join(source, file.path))
|
|
if (content.length !== file.bytes || hash(content) !== file.sha256) throw new Error('Sandbox Git metadata changed after capture')
|
|
const destination = join(target, file.path)
|
|
await fs.mkdir(dirname(destination), { recursive: true })
|
|
await fs.writeFile(destination, content, { flag: 'wx', mode: 0o600 })
|
|
}
|
|
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
|
|
if (bytes.length > limits.manifestBytes) throw new Error('Sandbox Git manifest exceeds the limit')
|
|
const handle = await fs.open(join(object, 'manifest.json'), 'wx', 0o600)
|
|
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
|
|
return { id, manifestSha256: hash(bytes) }
|
|
}
|
|
async function publishIdentity(root: string, identity: GitIdentity): Promise<void> {
|
|
const temporary = join(root, `active-${randomUUID()}.tmp`)
|
|
try {
|
|
const handle = await fs.open(temporary, 'wx', 0o600)
|
|
try { await handle.writeFile(JSON.stringify(identity) + '\n'); await handle.sync() } finally { await handle.close() }
|
|
await fs.rename(temporary, join(root, 'active.json'))
|
|
} finally { await fs.unlink(temporary).catch(() => undefined) }
|
|
}
|
|
|
|
/** Build one synthetic commit from approved input bytes; never open the host .git. */
|
|
async function initializeRepository(stage: string, files: Map<string, { hash: string; size: number }>, signal?: AbortSignal): Promise<void> {
|
|
const target = join(stage, 'repository'), objects = new Set<string>()
|
|
await fs.mkdir(join(target, 'objects'), { recursive: true })
|
|
const put = async (name: string, bytes: Buffer | string): Promise<void> => {
|
|
const path = join(target, name)
|
|
await fs.mkdir(dirname(path), { recursive: true })
|
|
await fs.writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
|
|
}
|
|
const object = async (type: string, bytes: Buffer): Promise<Buffer> => {
|
|
const content = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), id = sha1(content), hex = id.toString('hex')
|
|
if (!objects.has(hex)) { objects.add(hex); await put(`objects/${hex.slice(0, 2)}/${hex.slice(2)}`, await compress(content)) }
|
|
return id
|
|
}
|
|
interface Tree { files: Map<string, Buffer>; directories: Map<string, Tree> }
|
|
const tree = (): Tree => ({ files: new Map(), directories: new Map() }), root = tree()
|
|
const entries: Buffer[] = []
|
|
for (const [path, expected] of [...files].sort((a, b) => Buffer.compare(Buffer.from(a[0].replaceAll('\\', '/')), Buffer.from(b[0].replaceAll('\\', '/'))))) {
|
|
assertActive(signal)
|
|
const bytes = await regular(join(stage, 'workspace', path))
|
|
if (hash(bytes) !== expected.hash || bytes.length !== expected.size) throw new Error('Git baseline input changed')
|
|
const id = await object('blob', bytes), name = path.replaceAll('\\', '/')
|
|
const parts = name.split('/'); let parent = root
|
|
for (const part of parts.slice(0, -1)) { if (!parent.directories.has(part)) parent.directories.set(part, tree()); parent = parent.directories.get(part)! }
|
|
parent.files.set(parts.at(-1)!, id)
|
|
const nameBytes = Buffer.from(name), entrySize = 62 + nameBytes.length + 1
|
|
const entry = Buffer.alloc(Math.ceil(entrySize / 8) * 8)
|
|
entry.writeUInt32BE(0o100644, 24); entry.writeUInt32BE(bytes.length, 36); id.copy(entry, 40)
|
|
entry.writeUInt16BE(Math.min(nameBytes.length, 0xfff), 60); nameBytes.copy(entry, 62)
|
|
entries.push(entry)
|
|
}
|
|
const writeTree = async (value: Tree): Promise<Buffer> => {
|
|
const entries: Array<{ name: string; mode: string; id: Buffer }> = [...value.files].map(([name, id]) => ({ name, mode: '100644', id }))
|
|
for (const [name, child] of value.directories) entries.push({ name, mode: '40000', id: await writeTree(child) })
|
|
entries.sort((a, b) => Buffer.compare(Buffer.from(a.name + (a.mode === '40000' ? '/' : '')), Buffer.from(b.name + (b.mode === '40000' ? '/' : ''))))
|
|
return object('tree', Buffer.concat(entries.map(entry => Buffer.concat([Buffer.from(`${entry.mode} ${entry.name}\0`), entry.id]))))
|
|
}
|
|
const rootTree = await writeTree(root), timestamp = Math.floor(Date.now() / 1000)
|
|
const commit = await object('commit', Buffer.from(`tree ${rootTree.toString('hex')}\nauthor YCode Sandbox <sandbox@ycode.local> ${timestamp} +0000\ncommitter YCode Sandbox <sandbox@ycode.local> ${timestamp} +0000\n\nYCode sandbox baseline (filtered workspace snapshot; no host history)\n`))
|
|
const header = Buffer.alloc(12); header.write('DIRC'); header.writeUInt32BE(2, 4); header.writeUInt32BE(entries.length, 8)
|
|
const index = Buffer.concat([header, ...entries])
|
|
await put('index', Buffer.concat([index, sha1(index)]))
|
|
await put('HEAD', 'ref: refs/heads/ycode-sandbox\n')
|
|
await put('refs/heads/ycode-sandbox', `${commit.toString('hex')}\n`)
|
|
// The baseline records snapshot bytes, rather than host clean/smudge rules.
|
|
await put('info/attributes', '* -text -filter -ident -eol -working-tree-encoding\n')
|
|
await put('config', '[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tfilemode = false\n\tautocrlf = false\n\tignorecase = true\n\tsymlinks = false\n[user]\n\tname = YCode Sandbox\n\temail = sandbox@ycode.local\n')
|
|
}
|
|
|
|
export async function stageSandboxGit(stage: string, policy: SandboxEffectivePolicy, files: Map<string, { hash: string; size: number }>, signal?: AbortSignal, namespace?: string): Promise<SandboxGitSession> {
|
|
const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock')
|
|
const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Sandbox Git is in use; inspect repository.lock if the previous host exited'); throw error })
|
|
const close = async (): Promise<void> => { await lock.close(); await fs.unlink(lockPath) }
|
|
try {
|
|
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
|
|
let identity = await activeIdentity(root), initialized = false
|
|
const target = join(stage, 'repository')
|
|
if (!identity) {
|
|
await initializeRepository(stage, files, signal)
|
|
identity = await storeRepository(root, target, policy, await scanRepository(target, signal), signal)
|
|
await assertPolicyCurrent(policy)
|
|
await publishIdentity(root, identity)
|
|
initialized = true
|
|
} else {
|
|
const manifest = await manifestAt(root, identity, policy.workspaceRoot)
|
|
if (manifest.policyFingerprint !== policy.fingerprint) throw new Error('Sandbox Git policy changed; inspect git-state and explicitly git-reset before reusing history')
|
|
await fs.mkdir(join(target, 'objects'), { recursive: true }); await fs.mkdir(join(target, 'refs'), { recursive: true })
|
|
const source = join(root, 'objects', identity.id, 'files'), checked = new Set<string>([source])
|
|
for (const file of manifest.files) {
|
|
assertActive(signal)
|
|
const path = join(source, file.path); let parent = dirname(path)
|
|
while (!checked.has(parent)) { if (!within(source, parent)) throw new Error('Sandbox Git path escaped state'); await directory(parent); checked.add(parent); parent = dirname(parent) }
|
|
const bytes = await regular(path)
|
|
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git metadata digest mismatch')
|
|
await fs.mkdir(dirname(join(target, file.path)), { recursive: true })
|
|
await fs.writeFile(join(target, file.path), bytes, { flag: 'wx', mode: 0o600 })
|
|
}
|
|
}
|
|
const before = identity, manifest = await manifestAt(root, before, policy.workspaceRoot)
|
|
const mode = manifest.origin === 'host' ? 'host-mirror' : manifest.origin === 'remote' ? 'remote-clone' : manifest.origin === 'empty' ? 'new-repository' : 'private-snapshot'
|
|
const state: SandboxGitState = { mode, baseline: ({ 'host-mirror': 'host-repository', 'remote-clone': 'remote-repository', 'new-repository': 'empty-repository', 'private-snapshot': 'filtered-workspace' } as const)[mode], initialized, revision: before.id, manifestSha256: before.manifestSha256, persistence: 'not-applied', ...(namespace ? { scope: ({ 'host-mirror': 'host-draft', 'remote-clone': 'clone-draft', 'new-repository': 'new-draft', 'private-snapshot': 'draft' } as const)[mode] } : {}) }
|
|
const assertCurrent = async (): Promise<void> => {
|
|
assertActive(signal); await assertPolicyCurrent(policy)
|
|
const current = await activeIdentity(root)
|
|
if (current?.id !== before.id || current.manifestSha256 !== before.manifestSha256) throw new Error('Sandbox Git revision changed during execution or approval')
|
|
}
|
|
return { state, assertCurrent, close,
|
|
async capture(signal) {
|
|
const inventory = await scanRepository(target, signal)
|
|
if (hash(JSON.stringify(inventory.files)) === manifest.contentHash) return null
|
|
return storeRepository(root, target, policy, inventory, signal, manifest.origin)
|
|
},
|
|
async publish(candidate) {
|
|
await assertCurrent()
|
|
await manifestAt(root, candidate, policy.workspaceRoot)
|
|
await publishIdentity(root, candidate)
|
|
state.revision = candidate.id; state.manifestSha256 = candidate.manifestSha256; state.persistence = 'saved'
|
|
}
|
|
}
|
|
} catch (error) { await close(); throw error }
|
|
}
|
|
|
|
/** Trusted control-plane copy; validates every immutable source before publishing it. */
|
|
export async function copySandboxGitRepository(workspace: string, namespace: string, destination: string): Promise<{ identity: GitIdentity; manifest: GitManifest }> {
|
|
const root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock'), lock = await fs.open(lockPath, 'wx', 0o600)
|
|
try {
|
|
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
|
|
const identity = await activeIdentity(root)
|
|
if (!identity) throw new Error('Sandbox Git has no saved repository')
|
|
const manifest = await manifestAt(root, identity, workspace)
|
|
await fs.mkdir(destination, { recursive: true })
|
|
await fs.mkdir(join(destination, 'objects'), { recursive: true }); await fs.mkdir(join(destination, 'refs'), { recursive: true })
|
|
for (const file of manifest.files) {
|
|
const bytes = await regular(join(root, 'objects', identity.id, 'files', file.path))
|
|
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git saved repository was changed')
|
|
await fs.mkdir(dirname(join(destination, file.path)), { recursive: true })
|
|
await fs.writeFile(join(destination, file.path), bytes, { flag: 'wx', mode: 0o600 })
|
|
}
|
|
return { identity, manifest }
|
|
} finally { await lock.close(); await fs.unlink(lockPath) }
|
|
}
|
|
|
|
export async function replaceSandboxGitRepository(policy: SandboxEffectivePolicy, namespace: string, source: string, expected?: GitIdentity, origin?: 'host' | 'remote' | 'empty'): Promise<GitIdentity> {
|
|
const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock')
|
|
if (origin && !await fs.lstat(join(root, 'host-sealed')).catch(() => null)) {
|
|
if (await fs.lstat(lockPath).catch(() => null)) throw new Error('Sandbox Git is in use')
|
|
await sealWorkspace(root)
|
|
await fs.writeFile(join(root, 'host-sealed'), '1\n', { flag: 'wx' })
|
|
}
|
|
const lock = await fs.open(lockPath, 'wx', 0o600)
|
|
try {
|
|
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
|
|
const current = await activeIdentity(root)
|
|
if (expected && (current?.id !== expected.id || current.manifestSha256 !== expected.manifestSha256)) throw new Error('Sandbox Git revision changed during control-plane operation')
|
|
const next = await storeRepository(root, source, policy, await scanRepository(source), undefined, origin)
|
|
await assertPolicyCurrent(policy)
|
|
await publishIdentity(root, next)
|
|
return next
|
|
} finally { await lock.close(); await fs.unlink(lockPath) }
|
|
}
|
|
|
|
export async function sandboxGitIdentity(workspace: string, namespace: string): Promise<GitIdentity | null> {
|
|
return activeIdentity(await prepareRoot(workspace, namespace))
|
|
}
|
|
|
|
export async function sandboxGitState(workspaceInput: string): Promise<Record<string, unknown>> {
|
|
const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace), identity = await activeIdentity(root)
|
|
if (!identity) return { mode: 'private-snapshot', state: 'missing' }
|
|
const manifest = await manifestAt(root, identity, workspace)
|
|
return { mode: 'private-snapshot', baseline: 'filtered-workspace', state: 'saved', ...identity, policyFingerprint: manifest.policyFingerprint, files: manifest.files.length, bytes: manifest.bytes, createdAt: manifest.createdAt }
|
|
}
|
|
/** Archive only the active reference; immutable revisions remain available for inspection. */
|
|
export async function resetSandboxGit(workspaceInput: string, namespace?: string): Promise<void> {
|
|
const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock')
|
|
const lock = await fs.open(lockPath, 'wx', 0o600)
|
|
try {
|
|
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
|
|
if (await activeIdentity(root)) await fs.rename(join(root, 'active.json'), join(root, `archived-${randomUUID()}.json`))
|
|
} finally { await lock.close(); await fs.unlink(lockPath) }
|
|
}
|
|
export async function recoverSandboxGitLock(workspace: string, namespace: string): Promise<void> {
|
|
const path = join(repositoryRoot(workspace, namespace), 'repository.lock')
|
|
if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return
|
|
const bytes = await regular(path, 4096), value = JSON.parse(bytes.toString('utf8')) as { pid: number }
|
|
if (!Number.isSafeInteger(value.pid) || value.pid < 1) throw new Error('Sandbox Git lock identity is invalid')
|
|
try { process.kill(value.pid, 0); throw new Error('Sandbox Git lock still has a live owner') }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
if (!(await regular(path, 4096)).equals(bytes)) throw new Error('Sandbox Git lock changed during recovery')
|
|
await fs.unlink(path)
|
|
}
|