Inject serve-sim camera dylib from an unquarantined runtime copy (#7174)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d9e0b9e759
commit
a4661f15d3
|
|
@ -12,6 +12,7 @@ import { app } from 'electron'
|
|||
import { platform, tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { EmulatorError } from './emulator-errors'
|
||||
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
|
||||
|
||||
const EXEC_TIMEOUT_MS = 90_000
|
||||
const MAC_OPEN_SHIM_DIR = join(tmpdir(), 'orca-serve-sim-open-shim')
|
||||
|
|
@ -67,6 +68,27 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv {
|
|||
return env
|
||||
}
|
||||
|
||||
// Cached per process: materialization is a one-time copy; later calls only stat.
|
||||
let materializedServeSimPackageDir: string | null | undefined
|
||||
|
||||
function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null {
|
||||
if (materializedServeSimPackageDir !== undefined) {
|
||||
return materializedServeSimPackageDir
|
||||
}
|
||||
materializedServeSimPackageDir = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'),
|
||||
version: app.getVersion()
|
||||
})
|
||||
if (materializedServeSimPackageDir === null) {
|
||||
console.warn(
|
||||
'[serve-sim] runtime materialization failed; running from the app bundle ' +
|
||||
'(camera injection may hit Gatekeeper on quarantined installs)'
|
||||
)
|
||||
}
|
||||
return materializedServeSimPackageDir
|
||||
}
|
||||
|
||||
export function resolveServeSimExecutable(): ServeSimExecutable {
|
||||
const bundledResourcesPath =
|
||||
process.resourcesPath ??
|
||||
|
|
@ -75,6 +97,21 @@ export function resolveServeSimExecutable(): ServeSimExecutable {
|
|||
: join(app.getPath('exe'), '..', 'resources'))
|
||||
const bundled = join(bundledResourcesPath, 'serve-sim', 'dist', 'serve-sim.js')
|
||||
if (existsSync(bundled)) {
|
||||
if (process.platform === 'darwin') {
|
||||
// Why: the bundled camera dylib is signed but has no Gatekeeper ticket
|
||||
// (iOS-simulator arch), so injecting it from the quarantined app bundle
|
||||
// can trip syspolicyd; run serve-sim from an unquarantined copy instead.
|
||||
const materializedDir = resolveMaterializedServeSimPackageDir(
|
||||
join(bundledResourcesPath, 'serve-sim')
|
||||
)
|
||||
if (materializedDir) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
baseArgs: [join(materializedDir, 'dist', 'serve-sim.js')],
|
||||
usesElectronAsNode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command: process.execPath, baseArgs: [bundled], usesElectronAsNode: true }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
|
||||
|
||||
const DYLIB_CONTENT = Buffer.from('signed-simcam-dylib-mach-o-bytes')
|
||||
|
||||
async function createBundledServeSimPackage(root: string): Promise<string> {
|
||||
const packageDir = join(root, 'bundled-serve-sim')
|
||||
await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true })
|
||||
await mkdir(join(packageDir, 'bin'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")')
|
||||
await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT, {
|
||||
mode: 0o644
|
||||
})
|
||||
await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', {
|
||||
mode: 0o644
|
||||
})
|
||||
await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 })
|
||||
return packageDir
|
||||
}
|
||||
|
||||
describe('materializeServeSimRuntime', () => {
|
||||
const cleanupPaths: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
await rm(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-simcam-materializer-'))
|
||||
cleanupPaths.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
it('copies the signed dylib through unchanged and clears quarantine', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const clearQuarantine = vi.fn()
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine
|
||||
})
|
||||
|
||||
expect(materialized).toBe(join(root, 'runtime', '1.2.3'))
|
||||
// The dylib must be byte-identical to the bundled (Developer-ID-signed) copy.
|
||||
const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib')
|
||||
expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT)
|
||||
expect(clearQuarantine).toHaveBeenCalledTimes(1)
|
||||
expect(clearQuarantine).toHaveBeenCalledWith(expect.stringContaining('.staging-1.2.3-'))
|
||||
if (process.platform !== 'win32') {
|
||||
for (const executable of [
|
||||
join(materialized!, 'bin', 'serve-sim-bin'),
|
||||
join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper')
|
||||
]) {
|
||||
expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the existing runtime without re-copying', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const clearQuarantine = vi.fn()
|
||||
const options = {
|
||||
bundledPackageDir,
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine
|
||||
}
|
||||
|
||||
const first = materializeServeSimRuntime(options)
|
||||
const second = materializeServeSimRuntime(options)
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(clearQuarantine).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prunes runtimes left behind by older app versions', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const targetRootDir = join(root, 'runtime')
|
||||
await mkdir(join(targetRootDir, '1.0.0', 'dist'), { recursive: true })
|
||||
await writeFile(join(targetRootDir, '1.0.0', 'dist', 'serve-sim.js'), 'old')
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir,
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {}
|
||||
})
|
||||
|
||||
expect(materialized).toBe(join(targetRootDir, '1.2.3'))
|
||||
await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('tolerates a concurrent instance winning the rename', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const targetRootDir = join(root, 'runtime')
|
||||
const targetDir = join(targetRootDir, '1.2.3')
|
||||
|
||||
// Simulate another instance finishing first: right before our rename, drop a
|
||||
// complete target dir in place so renameSync fails but the entry exists.
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir,
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {
|
||||
mkdirSync(join(targetDir, 'dist'), { recursive: true })
|
||||
writeFileSync(join(targetDir, 'dist', 'serve-sim.js'), 'winner')
|
||||
}
|
||||
})
|
||||
|
||||
expect(materialized).toBe(targetDir)
|
||||
expect(await readFile(join(targetDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner')
|
||||
const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging'))
|
||||
expect(leftovers).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null and leaves no staging behind when quarantine clearing fails', async () => {
|
||||
const root = await createRoot()
|
||||
const bundledPackageDir = await createBundledServeSimPackage(root)
|
||||
const targetRootDir = join(root, 'runtime')
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir,
|
||||
targetRootDir,
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {
|
||||
throw new Error('xattr failed')
|
||||
}
|
||||
})
|
||||
|
||||
expect(materialized).toBeNull()
|
||||
await expect(stat(join(targetRootDir, '1.2.3'))).rejects.toThrow()
|
||||
const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging'))
|
||||
expect(leftovers).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null when the bundled package is missing', async () => {
|
||||
const root = await createRoot()
|
||||
|
||||
const materialized = materializeServeSimRuntime({
|
||||
bundledPackageDir: join(root, 'does-not-exist'),
|
||||
targetRootDir: join(root, 'runtime'),
|
||||
version: '1.2.3',
|
||||
clearQuarantine: () => {}
|
||||
})
|
||||
|
||||
expect(materialized).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export type ServeSimRuntimeMaterializerOptions = {
|
||||
bundledPackageDir: string
|
||||
targetRootDir: string
|
||||
version: string
|
||||
clearQuarantine?: (dir: string) => void
|
||||
}
|
||||
|
||||
const EXECUTABLE_RELATIVE_PATHS = [
|
||||
join('bin', 'serve-sim-bin'),
|
||||
join('dist', 'simcam', 'serve-sim-camera-helper')
|
||||
]
|
||||
|
||||
function defaultClearQuarantine(dir: string): void {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
// Why: a downloaded/updated .app carries com.apple.quarantine, and cpSync
|
||||
// clones it onto the copy. serve-sim DYLD-injects libSimCameraInjector.dylib
|
||||
// (an iOS-simulator binary Apple never Gatekeeper-tickets) into a simulator
|
||||
// process; if that copy is quarantined, syspolicyd malware-rejects the load.
|
||||
// Running from an unquarantined copy is what avoids the rejection (#6877).
|
||||
// Remove only the quarantine attribute (not `-cr`, which strips every xattr);
|
||||
// recursive `-d` exits 0 even for files that never had it.
|
||||
execFileSync('/usr/bin/xattr', ['-rd', 'com.apple.quarantine', dir], { timeout: 30_000 })
|
||||
}
|
||||
|
||||
function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(targetRootDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entryName of entries) {
|
||||
if (entryName === keepVersion) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
rmSync(join(targetRootDir, entryName), { recursive: true, force: true })
|
||||
} catch {
|
||||
// Old-version cleanup is best-effort; a locked file must not block materialization.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copies the bundled serve-sim package to a per-version directory outside the
|
||||
// signed app bundle and strips quarantine, so the camera dylib injected from
|
||||
// it is not subject to Gatekeeper assessment. The bundled dylib stays signed
|
||||
// and in place (it must, or the app fails notarization) — this only relocates
|
||||
// the copy that actually gets DYLD-injected. serve-sim resolves the dylib and
|
||||
// helper relative to its own entry, so the whole package moves together.
|
||||
export function materializeServeSimRuntime(
|
||||
options: ServeSimRuntimeMaterializerOptions
|
||||
): string | null {
|
||||
const { bundledPackageDir, targetRootDir, version } = options
|
||||
const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine
|
||||
const targetDir = join(targetRootDir, version)
|
||||
const entryPath = join(targetDir, 'dist', 'serve-sim.js')
|
||||
if (existsSync(entryPath)) {
|
||||
return targetDir
|
||||
}
|
||||
const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`)
|
||||
try {
|
||||
mkdirSync(targetRootDir, { recursive: true })
|
||||
pruneStaleServeSimRuntimes(targetRootDir, version)
|
||||
rmSync(stagingDir, { recursive: true, force: true })
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
cpSync(bundledPackageDir, stagingDir, { recursive: true })
|
||||
for (const relativePath of EXECUTABLE_RELATIVE_PATHS) {
|
||||
const executablePath = join(stagingDir, relativePath)
|
||||
if (existsSync(executablePath)) {
|
||||
chmodSync(executablePath, 0o755)
|
||||
}
|
||||
}
|
||||
clearQuarantine(stagingDir)
|
||||
try {
|
||||
renameSync(stagingDir, targetDir)
|
||||
} catch (error) {
|
||||
// Another app instance sharing userData may have finished first.
|
||||
if (!existsSync(entryPath)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return existsSync(entryPath) ? targetDir : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
rmSync(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue