fix: normalize pet bundle sprite paths (#3868)

This commit is contained in:
Neil 2026-05-30 12:46:34 -07:00 committed by GitHub
parent c43092a63d
commit 7e7562bd37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 121 additions and 2 deletions

112
src/main/ipc/pet.test.ts Normal file
View File

@ -0,0 +1,112 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
appGetPathMock,
browserWindowFromWebContentsMock,
browserWindowGetFocusedWindowMock,
handleMock,
nativeImageCreateFromBufferMock,
showOpenDialogMock
} = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
browserWindowGetFocusedWindowMock: vi.fn(),
handleMock: vi.fn(),
nativeImageCreateFromBufferMock: vi.fn(),
showOpenDialogMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: appGetPathMock
},
BrowserWindow: {
fromWebContents: browserWindowFromWebContentsMock,
getFocusedWindow: browserWindowGetFocusedWindowMock
},
dialog: {
showOpenDialog: showOpenDialogMock
},
ipcMain: {
handle: handleMock
},
nativeImage: {
createFromBuffer: nativeImageCreateFromBufferMock
}
}))
import { registerPetHandlers } from './pet'
import type { CustomPet } from '../../shared/types'
describe('registerPetHandlers', () => {
let tempDir: string
let userDataDir: string
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => Promise<unknown>>()
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-pet-test-'))
userDataDir = join(tempDir, 'user-data')
handlers.clear()
appGetPathMock.mockReset()
browserWindowFromWebContentsMock.mockReset()
browserWindowGetFocusedWindowMock.mockReset()
handleMock.mockReset()
nativeImageCreateFromBufferMock.mockReset()
showOpenDialogMock.mockReset()
appGetPathMock.mockReturnValue(userDataDir)
browserWindowFromWebContentsMock.mockReturnValue(null)
browserWindowGetFocusedWindowMock.mockReturnValue(null)
handleMock.mockImplementation((channel, handler) => {
handlers.set(channel, handler)
})
nativeImageCreateFromBufferMock.mockReturnValue({
isEmpty: () => true,
getSize: () => ({ width: 0, height: 0 })
})
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise<unknown> {
registerPetHandlers()
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`${channel} handler not registered`)
}
return handler
}
it('imports a pet bundle whose manifest uses Windows separators', async () => {
const bundleDir = join(tempDir, 'windows-export.codex-pet')
const sheetBytes = Buffer.from('not decoded without frame metadata')
await mkdir(join(bundleDir, 'assets'), { recursive: true })
await writeFile(
join(bundleDir, 'pet.json'),
JSON.stringify({
id: 'windows-export',
displayName: 'Windows export',
spritesheetPath: String.raw`assets\spritesheet.png`
})
)
await writeFile(join(bundleDir, 'assets', 'spritesheet.png'), sheetBytes)
showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] })
const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet
expect(result).toMatchObject({
label: 'Windows export',
fileName: 'spritesheet.png',
mimeType: 'image/png',
kind: 'bundle'
})
await expect(
readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png'))
).resolves.toEqual(sheetBytes)
})
})

View File

@ -315,10 +315,17 @@ export function registerPetHandlers(): void {
// that shape before path validation. Reject absolute paths and any resolved
// path that escapes the bundle directory. Also reject symlinks so a
// malicious bundle can't reach outside via a sibling link.
if (isAbsolute(manifest.spritesheetPath)) {
const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep)
if (
isAbsolute(manifest.spritesheetPath) ||
isAbsolute(normalizedSpritePath) ||
/^[a-zA-Z]:/.test(manifest.spritesheetPath)
) {
throw new Error('spritesheetPath must be relative to the bundle.')
}
const sheetSrc = resolve(bundleDir, manifest.spritesheetPath)
// Why: pet bundles may be exported on Windows and imported on macOS/Linux;
// normalize manifest separators before Node resolves the bundle-relative path.
const sheetSrc = resolve(bundleDir, normalizedSpritePath)
const bundleResolved = resolve(bundleDir)
if (sheetSrc === bundleResolved) {
throw new Error('spritesheetPath must point to a file, not the bundle root.')