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
This commit is contained in:
Yichen Jiang committed 2026-11-10 10:32:03 +08:00
1 parent 0eed137eca
commit 7d2d22aaf9
3 files changed
+41 -12

No files matched your search

@@ -44,7 +44,9 @@ export type FileTypeIconProps = IconProps & (
}
)
const EXTENSION_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
// 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<string, ClassifiedFileType> = new Map(Object.entries({
scss: 'code',
sass: 'code',
less: 'code',
@@ -101,13 +103,13 @@ const EXTENSION_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
ots: 'excel',
fods: 'excel',
numbers: 'excel',
}
}))
const NAME_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
const NAME_TYPES: ReadonlyMap<string, ClassifiedFileType> = 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'
}
@@ -61,7 +61,9 @@ export interface FileTypeProjectContext {
const CODE_FILE_TYPE_SET = new Set<string>(CODE_FILE_TYPES)
const FILE_NAME_TYPES: Readonly<Record<string, CodeFileType>> = {
// 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<string, CodeFileType> = new Map(Object.entries({
'.bash_profile': 'shell',
'.bashrc': 'shell',
'.env': 'env',
@@ -90,7 +92,7 @@ const FILE_NAME_TYPES: Readonly<Record<string, CodeFileType>> = {
'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<Record<string, CodeFileType>> = {
const EXTENSION_TYPES: ReadonlyMap<string, CodeFileType> = new Map(Object.entries({
'bash': 'shell',
'c': 'c',
'c++': 'cpp',
@@ -217,7 +219,7 @@ const EXTENSION_TYPES: Readonly<Record<string, CodeFileType>> = {
'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
}
@@ -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<FileTypeKind, 'folder'>][]))('%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(<FileTypeIcon path={path} />)
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',