diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cf330430661..19b5f42eabb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -81,6 +81,7 @@ jobs: /config/scripts/pr-layout-oracle-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/pr-browser-e2e-source-routes.mjs + /config/scripts/pr-editor-preview-e2e-source-routes.mjs /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs sparse-checkout-cone-mode: false diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index f516e8b4bd9..872e282a710 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -388,6 +388,7 @@ function createMobileCiScopeRepository( 'config/scripts/git-pull-request-diff-base.mjs', 'config/scripts/pr-e2e-source-routing.mjs', 'config/scripts/pr-browser-e2e-source-routes.mjs', + 'config/scripts/pr-editor-preview-e2e-source-routes.mjs', 'config/scripts/ci-e2e-job-selection.mjs' ]) { mkdirSync(dirname(join(directory, file)), { recursive: true }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 6c3c1f50f4e..d1977d35202 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -8,6 +8,7 @@ import { import { MANAGED_SSH_E2E_SPEC_PREFIX } from './ci-e2e-job-selection.mjs' import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' import { PR_BROWSER_E2E_SOURCE_ROUTES } from './pr-browser-e2e-source-routes.mjs' +import { PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES } from './pr-editor-preview-e2e-source-routes.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) @@ -35,6 +36,7 @@ const ENABLED_AUTOMATION_MOVE = export const PR_E2E_SOURCE_ROUTES = [ ...PR_BROWSER_E2E_SOURCE_ROUTES, + ...PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES, { id: 'ssh.orcad-editor-ownership', specs: ['tests/e2e/ssh-orcad-editor-ownership.spec.ts'], diff --git a/config/scripts/pr-editor-preview-e2e-source-routes.mjs b/config/scripts/pr-editor-preview-e2e-source-routes.mjs new file mode 100644 index 00000000000..902a528b92f --- /dev/null +++ b/config/scripts/pr-editor-preview-e2e-source-routes.mjs @@ -0,0 +1,35 @@ +import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' + +const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) + +export const PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES = [ + { + // Image, PDF and media viewers read orca-media grants; the PDF reloads exercise remint and range reads. + id: 'editor.byte-range-preview', + specs: [ + 'tests/e2e/editor-media-playback-position.spec.ts', + 'tests/e2e/media-preview-partition-isolation.spec.ts', + 'tests/e2e/paired-remote-large-file-open.spec.ts', + 'tests/e2e/pdf-find-counter.spec.ts', + 'tests/e2e/pdf-viewer-rebuild-recovery.spec.ts', + 'tests/e2e/pdf-viewer-reload-cleanup.spec.ts' + ], + matches: (file) => + isProductSource(file) && + (/^src\/main\/media\/[^/]+\.ts$/.test(file) || + /^src\/renderer\/src\/components\/editor\/(?:ImageViewer|PdfViewer|pdf-document-loader|pdf-js-document-options|pdf-orca-media-range-transport)\.tsx?$/.test( + file + ) || + /^src\/renderer\/src\/components\/editor\/(?:EditorEditFileSurface|EditorConflictReviewSurface)\.tsx$/.test( + file + ) || + [ + 'src/main/ipc/filesystem/filesystem-read-handlers.ts', + 'src/main/startup/privileged-custom-schemes.ts', + 'src/preload/api/fs-bridge.ts', + 'src/renderer/src/runtime/runtime-file-range-preview.ts', + 'src/renderer/src/runtime/runtime-file-read-client.ts', + 'src/shared/file-preview-kind.ts' + ].includes(file)) + } +] diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index 94460a84fff..2014a4ee99e 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -17,6 +17,8 @@ export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 export type LocalFileContent = { mediaUrl?: string + /** Bytes behind `mediaUrl`, for viewers that no longer see the content. */ + size?: number content: string isBinary: boolean isImage?: boolean diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 56aae691657..ee039362520 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -23,7 +23,8 @@ import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-st import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { registerFilesystemChunkReadHandler } from './filesystem-chunk-read-handler' -import { grantRemoteFilePreview, readMediaPreview } from '../../media/media-preview-protocol' +import { grantRemoteFilePreview, readLocalFilePreview } from '../../media/media-preview-protocol' +import { readPreviewGrantRange } from '../../media/preview-grant-range-read' import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path' import { assertWslUncPathRunning, guardWslUncPathAccess } from '../../wsl/wsl-unc-access-gate' import { parseRemoteFilePreviewAddress } from '../../../shared/remote-file-preview-address' @@ -98,6 +99,8 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext access?: LocalFileAccess /** A user act (open, expand, Retry); may start a stopped WSL distro. */ wakeWslDistro?: boolean + /** A viewer that reads images and PDFs by URL; other readers keep their base64. */ + rangePreview?: boolean } ): Promise => { if (args.connectionId) { @@ -107,7 +110,9 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext args.filePath, async () => { const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) - const media = readMediaPreview(event, args, store) + const media = await readLocalFilePreview(event, args, store, { + documents: args.rangePreview === true + }) if (media) { return media } @@ -120,6 +125,12 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext } ) + ipcMain.handle( + 'fs:readPreviewRange', + (event, args: { url: string; offset: number; length: number }) => + readPreviewGrantRange(event.sender, String(args?.url), args?.offset, args?.length) + ) + ipcMain.handle('fs:grantRuntimeFilePreview', (event, args: unknown) => { const remote = parseRemoteFilePreviewAddress(args) if (!remote) { diff --git a/src/main/media/local-preview-grant-file.ts b/src/main/media/local-preview-grant-file.ts new file mode 100644 index 00000000000..45a2f3b6544 --- /dev/null +++ b/src/main/media/local-preview-grant-file.ts @@ -0,0 +1,27 @@ +import type { Store } from '../persistence' +import { filePreviewForPath, isDocumentPreview } from '../../shared/file-preview-kind' +import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' +import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' +import { guardWslUncPathAccess } from '../wsl/wsl-unc-access-gate' +import type { LocalPreviewTarget, PreviewGrant } from './preview-grant-registry' + +/** + * Opens a local grant's file for a lazy read. It never wakes a stopped WSL distro, and a document + * grant ends once its link points at another kind of file. + */ +export function openLocalPreviewGrantFile( + grant: Pick, + target: LocalPreviewTarget, + store: Store +): ReturnType { + return guardWslUncPathAccess(target.filePath, async () => { + const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store) + if ( + isDocumentPreview(grant.preview) && + filePreviewForPath(filePath)?.mimeType !== grant.mimeType + ) { + throw new Error('Preview is no longer available') + } + return openLocalRegularFile(filePath) + }) +} diff --git a/src/main/media/media-preview-protocol.test.ts b/src/main/media/media-preview-protocol.test.ts index 5014b4da32a..64367470d79 100644 --- a/src/main/media/media-preview-protocol.test.ts +++ b/src/main/media/media-preview-protocol.test.ts @@ -23,23 +23,36 @@ vi.mock('./remote-file-preview-reader', async (importOriginal) => ({ ...(await importOriginal()), statRemotePreviewFile: vi.fn(), readRemotePreviewChunk: vi.fn(), + readRemotePreviewRange: vi.fn(), assertRemoteChunkReadsSupported: vi.fn() })) import { + MEDIA_PREVIEW_CUSTOM_SCHEME, grantRemoteFilePreview, handleMediaPreviewRequest, - readMediaPreview, + readLocalFilePreview, revokeRemoteFilePreviewGrants } from './media-preview-protocol' import { + REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE, RemotePreviewPairingChangedError, assertRemoteChunkReadsSupported, readRemotePreviewChunk, + readRemotePreviewRange, statRemotePreviewFile } from './remote-file-preview-reader' import type * as RemoteFilePreviewReader from './remote-file-preview-reader' +function png(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(33) + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]) + new DataView(bytes.buffer).setUint32(16, width) + new DataView(bytes.buffer).setUint32(20, height) + bytes.set([8, 6, 0, 0, 0], 24) + return bytes +} + describe('media preview protocol', () => { let directory: string const emitter = new EventEmitter() @@ -70,7 +83,7 @@ describe('media preview protocol', () => { const filePath = join(directory, `clip.${extension}`) await writeFile(filePath, Uint8Array.from([0, 1, 2, 3, 4, 5])) const target = { filePath, access: { kind: 'user-file' } as const } - const result = readMediaPreview(event, target, store) + const result = await readLocalFilePreview(event, target, store, { documents: false }) expect(result).toMatchObject({ content: '', isBinary: true }) expect(result?.mediaUrl).not.toContain(filePath) if (!result) { @@ -88,7 +101,7 @@ describe('media preview protocol', () => { it('rechecks authorization for every seek and revokes URLs with the renderer', async () => { const filePath = join(directory, 'clip.mp4') await writeFile(filePath, 'video') - const result = readMediaPreview(event, { filePath }, store) + const result = await readLocalFilePreview(event, { filePath }, store, { documents: false }) if (!result) { throw new Error('Missing preview') } @@ -100,7 +113,12 @@ describe('media preview protocol', () => { }) it('rejects directories, forged URLs and non-media paths', async () => { - const result = readMediaPreview(event, { filePath: join(directory, 'folder.mp4') }, store) + const result = await readLocalFilePreview( + event, + { filePath: join(directory, 'folder.mp4') }, + store, + { documents: false } + ) if (!result) { throw new Error('Missing preview') } @@ -109,14 +127,101 @@ describe('media preview protocol', () => { expect((await handleMediaPreviewRequest(new Request('orca-media://file/unknown'))).status).toBe( 404 ) - expect(readMediaPreview(event, { filePath: join(directory, 'secret.txt') }, store)).toBeNull() + expect( + await readLocalFilePreview(event, { filePath: join(directory, 'secret.txt') }, store, { + documents: false + }) + ).toBeNull() + }) + + it('mints an image or PDF URL only for a viewer that asks, with its size', async () => { + const filePath = join(directory, 'shot.png') + await writeFile(filePath, png(4, 3)) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: false }) + ).resolves.toBeNull() + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + expect(result).toMatchObject({ isImage: true, mimeType: 'image/png', size: 33 }) + const response = await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? '')) + // No CORS: no other origin can read these bytes with fetch. + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + expect(new Uint8Array(await response.arrayBuffer())).toEqual(png(4, 3)) + }) + + it('classifies a linked image or PDF by its target, then and on every read', async () => { + const filePath = join(directory, 'shot.png') + const drawing = join(directory, 'drawing.svg') + await writeFile(filePath, png(4, 3)) + await writeFile(drawing, '') + authorize.mockResolvedValueOnce(drawing) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: true }) + ).resolves.toMatchObject({ mimeType: 'image/svg+xml' }) + authorize.mockResolvedValueOnce(join(directory, 'notes.txt')) + expect(await readLocalFilePreview(event, { filePath }, store, { documents: true })).toBeNull() + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + authorize.mockResolvedValueOnce(drawing) + expect((await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? ''))).status).toBe(404) + }) + + it('refuses an image whose header proves it too large to decode safely', async () => { + const filePath = join(directory, 'bomb.png') + await writeFile(filePath, png(100_000, 100_000)) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: true }) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') + }) + + it('mints nothing for a renderer torn down while the image was being checked', async () => { + const filePath = join(directory, 'late.png') + await writeFile(filePath, png(4, 3)) + let release: () => void = () => {} + authorize.mockImplementationOnce( + (path) => new Promise((resolve) => (release = () => resolve(path))) + ) + const pending = readLocalFilePreview(event, { filePath }, store, { documents: true }) + await vi.waitFor(() => expect(authorize).toHaveBeenCalled()) + emitter.emit('destroyed') + release() + await expect(pending).rejects.toThrow() + }) + + it('gives another origin no CORS answer, preflight or not', async () => { + const filePath = join(directory, 'doc.pdf') + await writeFile(filePath, '%PDF-1.4') + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + const url = result?.mediaUrl ?? '' + const fromPage = await handleMediaPreviewRequest( + new Request(url, { headers: { origin: 'https://example.com' } }) + ) + expect(fromPage.headers.get('Access-Control-Allow-Origin')).toBeNull() + const preflight = await handleMediaPreviewRequest(new Request(url, { method: 'OPTIONS' })) + expect(preflight.status).toBe(405) + expect(preflight.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('claims only the element-load privileges, never fetch or CORS', () => { + expect(MEDIA_PREVIEW_CUSTOM_SCHEME.privileges).toEqual({ + standard: true, + secure: true, + stream: true + }) + }) + + it('checks the image it serves, not just the one it minted', async () => { + const filePath = join(directory, 'swapped.png') + await writeFile(filePath, png(4, 3)) + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + await writeFile(filePath, png(100_000, 100_000)) + expect((await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? ''))).status).toBe(404) }) it('does not try a local read for an SSH host', async () => { - const result = readMediaPreview( + const result = await readLocalFilePreview( event, { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, - store + store, + { documents: false } ) if (!result) { throw new Error('Missing preview') @@ -173,6 +278,68 @@ describe('media preview protocol', () => { expect(readRemotePreviewChunk).toHaveBeenCalledWith(pinned, 2, 3, expect.any(AbortSignal)) }) + it('streams a remote image or PDF to its viewer, refusing a proven-oversized image', async () => { + vi.mocked(readRemotePreviewRange).mockImplementation(async (address) => + address.relativePath.endsWith('.pdf') ? new TextEncoder().encode('%PDF-1.7') : png(4, 3) + ) + await expect( + grantRemoteFilePreview(event, addr('docs/shot.png'), store) + ).resolves.toMatchObject({ + isImage: true, + mimeType: 'image/png', + size: 6 + }) + await expect( + grantRemoteFilePreview(event, addr('docs/spec.pdf'), store) + ).resolves.toMatchObject({ + isImage: true, + mimeType: 'application/pdf' + }) + vi.mocked(readRemotePreviewRange).mockResolvedValue(png(100_000, 100_000)) + await expect(grantRemoteFilePreview(event, addr('docs/bomb.png'), store)).rejects.toThrow( + 'Image dimensions exceed the preview safety limit' + ) + vi.mocked(readRemotePreviewRange).mockImplementation(async (address) => + address.relativePath.endsWith('.pdf') ? new TextEncoder().encode('%PDF-1.7') : png(4, 3) + ) + vi.mocked(statRemotePreviewFile).mockResolvedValueOnce({ size: 60 * 1024 * 1024, mtime: 1 }) + // An decodes the whole file, so images keep the 50 MB cap; PDFs read by range don't. + await expect(grantRemoteFilePreview(event, addr('docs/huge.png'), store)).rejects.toThrow( + 'File too large' + ) + vi.mocked(statRemotePreviewFile).mockResolvedValueOnce({ size: 60 * 1024 * 1024, mtime: 1 }) + await expect( + grantRemoteFilePreview(event, addr('docs/huge.pdf'), store) + ).resolves.toMatchObject({ + size: 60 * 1024 * 1024 + }) + }) + + it("leaves an older host's images and PDFs on files.readPreview, and refuses a swapped image", async () => { + vi.mocked(assertRemoteChunkReadsSupported).mockRejectedValueOnce( + new Error(REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE) + ) + await expect(grantRemoteFilePreview(event, addr('docs/spec.pdf'), store)).resolves.toBeNull() + + vi.mocked(readRemotePreviewRange).mockResolvedValueOnce(png(4, 3)) + const minted = await grantRemoteFilePreview(event, addr('docs/shot.png'), store) + vi.mocked(readRemotePreviewRange).mockResolvedValueOnce(png(100_000, 100_000)) + expect((await range(minted?.mediaUrl ?? '')).status).not.toBe(206) + }) + + it('leaves a remote image or PDF whose bytes are another kind to files.readPreview', async () => { + vi.mocked(readRemotePreviewRange).mockResolvedValue(new TextEncoder().encode('')) + await expect(grantRemoteFilePreview(event, addr('docs/shot.png'), store)).resolves.toBeNull() + await expect(grantRemoteFilePreview(event, addr('docs/spec.pdf'), store)).resolves.toBeNull() + await expect( + grantRemoteFilePreview(event, addr('docs/drawing.svg'), store) + ).resolves.toMatchObject({ mimeType: 'image/svg+xml' }) + vi.mocked(readRemotePreviewRange).mockResolvedValue(png(4, 3)) + await expect( + grantRemoteFilePreview(event, addr('docs/drawing.svg'), store) + ).resolves.toBeNull() + }) + it('fails the mint, not the player, when the host cannot serve chunk reads', async () => { vi.mocked(assertRemoteChunkReadsSupported).mockRejectedValue(new Error('update Orca')) await expect(grantRemoteFilePreview(event, addr('out/clip.mp4'), store)).rejects.toThrow( diff --git a/src/main/media/media-preview-protocol.ts b/src/main/media/media-preview-protocol.ts index ff4acb650f1..4a7d859e50e 100644 --- a/src/main/media/media-preview-protocol.ts +++ b/src/main/media/media-preview-protocol.ts @@ -1,116 +1,101 @@ -import { randomUUID } from 'node:crypto' -import { protocol, type WebContents } from 'electron' +import { protocol } from 'electron' import type { Store } from '../persistence' -import type { LocalFileAccess } from '../../shared/local-file-access' import { MEDIA_PREVIEW_SCHEME } from '../../shared/media-file-extensions' -import { filePreviewForPath, streamsPreviewByRange } from '../../shared/file-preview-kind' +import { + filePreviewForPath, + isDocumentPreview, + streamsPreviewByRange, + type FilePreview +} from '../../shared/file-preview-kind' +import { + assertLocalPreviewRasterHeader, + assertPreviewableRasterHeader, + assertRemotePreviewRasterHeader, + incrementalPrefixReader +} from './preview-raster-header-check' +import { + DOCUMENT_SIGNATURE_PROBE_BYTES, + prefixFitsDocumentPreview +} from './preview-document-signature' +import { openLocalPreviewGrantFile } from './local-preview-grant-file' import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address' import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' import { createMediaRangeResponse } from './media-range-response' import { + REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE, RemotePreviewPairingChangedError, assertRemoteChunkReadsSupported, readRemotePreviewChunk, + readRemotePreviewRange, statRemotePreviewFile, type RemotePreviewFileSnapshot } from './remote-file-preview-reader' import { readRegisteredPairingRevision } from '../ipc/runtime-environments/runtime-environment-registry-root' +import { + grants, + mintPreviewUrl, + remoteSnapshot, + revokeGrant, + type LocalPreviewTarget, + type PreviewGrant, + type PreviewSender, + type PreviewUrl +} from './preview-grant-registry' -type LocalPreviewTarget = { filePath: string; connectionId?: string; access?: LocalFileAccess } -type RemotePreviewTarget = { remote: RemoteFilePreviewAddress } -type PreviewTarget = LocalPreviewTarget | RemotePreviewTarget -type PreviewGrant = { - target: PreviewTarget - store: Store - mimeType: string - senderId: number - key: string - // Why: a seek or prefetch burst opens several ranges at once; one stat answers all of them. - stat?: { at: number; snapshot: Promise } -} -type PreviewUrl = { content: string; isBinary: true; mimeType: string; mediaUrl: string } - -const REMOTE_STAT_REUSE_MS = 1_000 -const grants = new Map() -const senders = new Map>() +export { revokeRemoteFilePreviewGrants } from './preview-grant-registry' export const MEDIA_PREVIEW_CUSTOM_SCHEME: Electron.CustomScheme = { scheme: MEDIA_PREVIEW_SCHEME, + // No fetch or CORS: only /