From 7d2d22aaf96bb54754209e46579de1735febdf66 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 10 Nov 2026 10:32:03 +0800 Subject: [PATCH] fix(ui-primitives): look up file types in Maps so prototype keys miss classifyFileType and classifyCodeFileType indexed plain objects by the lowercased basename and extension, so a file named `constructor` or `__proto__` (or with that extension) resolved an inherited Object.prototype member and FileTypeIcon threw from its assertNever, crashing every view that lists the file. The name and extension tables are now Maps, so such names fall through to their real type or `other`. Fixes #8205 --- .../client/ui-primitives/src/FileTypeIcon.tsx | 14 ++++++----- .../ui-primitives/src/code-file-types.ts | 14 ++++++----- .../tests/file-type-icon.client.spec.tsx | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index 1fbcee6750..fc2e3c2667 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -44,7 +44,9 @@ export type FileTypeIconProps = IconProps & ( } ) -const EXTENSION_TYPES: Readonly> = { +// Maps, not objects: a basename or extension equal to an `Object.prototype` key +// (`constructor`, `__proto__`) must miss instead of resolving the inherited member. +const EXTENSION_TYPES: ReadonlyMap = new Map(Object.entries({ scss: 'code', sass: 'code', less: 'code', @@ -101,13 +103,13 @@ const EXTENSION_TYPES: Readonly> = { ots: 'excel', fods: 'excel', numbers: 'excel', -} +})) -const NAME_TYPES: Readonly> = { +const NAME_TYPES: ReadonlyMap = new Map(Object.entries({ changelog: 'markdown', contributing: 'markdown', readme: 'markdown', -} +})) function basename(path: string): string { return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) @@ -140,8 +142,8 @@ export function classifyFileType( const name = basename(path).toLowerCase() const extension = fileExtension(name).toLowerCase() return classifyCodeFileType(name, extension, context) - ?? NAME_TYPES[name] - ?? EXTENSION_TYPES[extension] + ?? NAME_TYPES.get(name) + ?? EXTENSION_TYPES.get(extension) ?? 'other' } diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts index 9b5ce75459..5bb1a5a628 100644 --- a/packages/client/ui-primitives/src/code-file-types.ts +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -61,7 +61,9 @@ export interface FileTypeProjectContext { const CODE_FILE_TYPE_SET = new Set(CODE_FILE_TYPES) -const FILE_NAME_TYPES: Readonly> = { +// Maps, not objects: a basename or extension equal to an `Object.prototype` key +// (`constructor`, `__proto__`) must miss instead of resolving the inherited member. +const FILE_NAME_TYPES: ReadonlyMap = new Map(Object.entries({ '.bash_profile': 'shell', '.bashrc': 'shell', '.env': 'env', @@ -90,7 +92,7 @@ const FILE_NAME_TYPES: Readonly> = { 'package.json': 'node', 'podfile': 'ruby', 'rakefile': 'ruby', -} +})) const FILE_NAME_PREFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ ['dockerfile.', 'docker'], @@ -109,7 +111,7 @@ const FILE_NAME_SUFFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ ['.dockerfile', 'docker'], ] -const EXTENSION_TYPES: Readonly> = { +const EXTENSION_TYPES: ReadonlyMap = new Map(Object.entries({ 'bash': 'shell', 'c': 'c', 'c++': 'cpp', @@ -217,7 +219,7 @@ const EXTENSION_TYPES: Readonly> = { 'yml': 'yaml', 'zig': 'zig', 'zsh': 'shell', -} +})) const LINK_CODE_EXTENSIONS = new Set([ 'ts', @@ -322,7 +324,7 @@ export function classifyCodeFileType( extension: string, context?: FileTypeProjectContext, ): CodeFileType | null { - const exact = FILE_NAME_TYPES[name] + const exact = FILE_NAME_TYPES.get(name) if (exact !== undefined) return exact for (const [prefix, type] of FILE_NAME_PREFIX_TYPES) { if (name.startsWith(prefix)) return type @@ -334,5 +336,5 @@ export function classifyCodeFileType( const pubspec = Object.entries(context.files).find(([path]) => basename(path).toLowerCase() === 'pubspec.yaml')?.[1] if (pubspec?.includes('flutter:') === true) return 'flutter' } - return EXTENSION_TYPES[extension] ?? null + return EXTENSION_TYPES.get(extension) ?? null } diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx index b39b22f0f2..e9c8367d59 100644 --- a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { classifyFileType, fileExtension, FileTypeIcon, type FileTypeKind, } from '@deepseek-ai/dsh-client-ui-primitives' +import { classifyCodeFileType } from '../src/code-file-types.ts' import css from '../src/FileTypeIcon.module.css' afterEach(cleanup) @@ -58,6 +59,30 @@ describe('classifyFileType', () => { }) }) +describe('Object.prototype key names', () => { + const keys = ['constructor', '__proto__', 'toString', 'hasOwnProperty'] + + it.each(keys.flatMap(key => [ + [key, 'other'], + [`src/${key.toUpperCase()}`, 'other'], + [`archive.${key}`, 'other'], + [`${key}.md`, 'markdown'], + [`${key}.ts`, 'typescript'], + ] as [string, Exclude][]))('%s → %s', (path, type) => { + expect(classifyFileType(path)).toBe(type) + }) + + it.each(keys)('%s misses the code-file name and extension tables', (key) => { + expect(classifyCodeFileType(key, key)).toBeNull() + expect(classifyCodeFileType(key, 'ts')).toBe('typescript') + }) + + it.each(keys.flatMap(key => [key, `notes.${key}`]))('%s renders the generic file glyph', (path) => { + const { container } = render() + expect(container.querySelector('svg')!.classList.contains(css.other!)).toBe(true) + }) +}) + describe('FileTypeIcon', () => { const types: FileTypeKind[] = [ 'code', 'excel', 'folder', 'html', 'image', 'markdown', 'other', 'pdf', 'ppt', 'video', 'word',