diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts new file mode 100644 index 000000000..59311df56 --- /dev/null +++ b/src/main/ipc/pet.test.ts @@ -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 Promise>() + + 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 { + 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) + }) +}) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index de3d11b59..2a6c0e13c 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -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.')