Skip to content

Commit cf83cf0

Browse files
committed
fix(detector): reject binary input to prevent false positives
Binary files contain null bytes that get parsed into JSX-like AST nodes. Add a null-byte guard in containsJSX to short-circuit on binary content.
1 parent b8a500c commit cf83cf0

File tree

13 files changed

+168
-117
lines changed

13 files changed

+168
-117
lines changed

package-lock.json

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
"type": "module",
66
"main": "./dist/index.cjs",
77
"module": "./dist/index.mjs",
8+
"types": "./dist/index.d.mts",
89
"bin": {
910
"has-jsx": "bin/has-jsx.js"
1011
},
1112
"exports": {
1213
".": {
14+
"types": "./dist/index.d.mts",
1315
"import": "./dist/index.mjs",
1416
"require": "./dist/index.cjs",
1517
"default": "./dist/index.mjs"
@@ -55,7 +57,9 @@
5557
"commander": "^14.0.3"
5658
},
5759
"devDependencies": {
60+
"@types/node": "^25.2.3",
5861
"tsdown": "^0.20.3",
62+
"typescript": "^5.7",
5963
"vitest": "^4.0.18"
6064
}
6165
}

src/cli.js renamed to src/cli.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import { hasJSX, hasJSXInString } from './index.js';
99
const __filename = fileURLToPath(import.meta.url);
1010
const __dirname = dirname(__filename);
1111

12-
/**
13-
* Runs the CLI with the provided arguments.
14-
*
15-
* @param {string[]} argv - Command line arguments (process.argv)
16-
* @returns {Promise<void>}
17-
*/
18-
export async function run(argv) {
12+
interface CliOptions {
13+
file?: string;
14+
verbose?: boolean;
15+
quiet?: boolean;
16+
}
17+
18+
export async function run(argv: string[]): Promise<void> {
1919
const packageJson = JSON.parse(
2020
readFileSync(join(__dirname, '../package.json'), 'utf-8')
2121
);
@@ -32,14 +32,14 @@ export async function run(argv) {
3232
.option('-q, --quiet', 'Silent mode (exit codes only)')
3333
.exitOverride()
3434
.configureOutput({
35-
writeOut: (str) => console.log(str.trimEnd()),
36-
writeErr: (str) => console.error(str.trimEnd()),
35+
writeOut: (str: string) => console.log(str.trimEnd()),
36+
writeErr: (str: string) => console.error(str.trimEnd()),
3737
})
38-
.action(async (source, options) => {
38+
.action(async (source: string | undefined, options: CliOptions) => {
3939
try {
40-
let result;
41-
let inputType;
42-
let inputValue;
40+
let result: boolean;
41+
let inputType: 'file' | 'string';
42+
let inputValue: string;
4343

4444
if (options.file) {
4545
result = await hasJSX(options.file);
@@ -70,31 +70,37 @@ export async function run(argv) {
7070
console.log(result ? 'JSX detected' : 'No JSX detected');
7171
process.exit(exitCode);
7272
}
73-
} catch (error) {
73+
} catch (error: unknown) {
7474
if (isProcessExitError(error)) {
7575
throw error;
7676
}
7777
if (!options.quiet) {
78-
console.error(`Error: ${error.message}`);
78+
console.error(`Error: ${(error as Error).message}`);
7979
}
8080
process.exit(2);
8181
}
8282
});
8383

8484
try {
8585
await program.parseAsync(argv);
86-
} catch (error) {
86+
} catch (error: unknown) {
8787
if (isProcessExitError(error) || isCommanderError(error)) {
8888
return;
8989
}
9090
throw error;
9191
}
9292
}
9393

94-
function isProcessExitError(error) {
95-
return error.message?.startsWith('process.exit(');
94+
function isProcessExitError(error: unknown): boolean {
95+
return error instanceof Error && error.message.startsWith('process.exit(');
9696
}
9797

98-
function isCommanderError(error) {
99-
return error.code?.startsWith('commander.');
98+
function isCommanderError(error: unknown): boolean {
99+
return (
100+
typeof error === 'object' &&
101+
error !== null &&
102+
'code' in error &&
103+
typeof (error as { code: unknown }).code === 'string' &&
104+
(error as { code: string }).code.startsWith('commander.')
105+
);
100106
}

src/detector.js

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/detector.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { tsx } from '@ast-grep/napi';
2+
3+
/** Detects if source code contains JSX syntax using AST analysis. */
4+
export function containsJSX(source: string): boolean {
5+
// Binary files contain null bytes; valid source code never does.
6+
// Without this guard, garbled UTF-8 from binaries gets parsed into JSX-like AST nodes.
7+
if (source.includes('\0')) return false;
8+
9+
try {
10+
const root = tsx.parse(source).root();
11+
12+
// jsx_element covers standard elements and fragments (<>...</>)
13+
const match = root.find({
14+
rule: {
15+
any: [
16+
{ kind: 'jsx_element' },
17+
{ kind: 'jsx_self_closing_element' },
18+
],
19+
},
20+
});
21+
22+
return match !== null;
23+
} catch {
24+
return false;
25+
}
26+
}

src/index.js

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { readFileContent } from './utils.js';
2+
import { containsJSX } from './detector.js';
3+
4+
/** Checks if a file contains JSX syntax. */
5+
export async function hasJSX(filepath: string): Promise<boolean> {
6+
const content = await readFileContent(filepath);
7+
return containsJSX(content);
8+
}
9+
10+
/** Checks if a string contains JSX syntax. */
11+
export function hasJSXInString(source: string): boolean {
12+
if (typeof source !== 'string') {
13+
throw new TypeError('Expected source to be a string');
14+
}
15+
return containsJSX(source);
16+
}
17+
18+
export default hasJSX;

src/utils.js renamed to src/utils.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
import { readFile } from 'fs/promises';
22
import { existsSync, statSync } from 'fs';
33

4-
/**
5-
* Reads file content from the filesystem with validation.
6-
*
7-
* @param {string} filepath - Absolute or relative path to file
8-
* @returns {Promise<string>} File content
9-
* @throws {Error} If file doesn't exist, is not a file, or can't be read
10-
*/
11-
export async function readFileContent(filepath) {
4+
/** Reads file content from the filesystem with validation. */
5+
export async function readFileContent(filepath: string): Promise<string> {
126
if (!existsSync(filepath)) {
137
throw new Error(`File not found: ${filepath}`);
148
}

test/api.test.js renamed to test/api.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ describe('hasJSX API', () => {
5656
});
5757
});
5858

59+
describe('Binary content (no false positives)', () => {
60+
it('returns false for binary content', () => {
61+
const binary = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x3c, 0x64, 0x69, 0x76, 0x3e]).toString('utf-8');
62+
expect(hasJSXInString(binary)).toBe(false);
63+
});
64+
});
65+
5966
describe('Error handling', () => {
6067
it('throws error for nonexistent file', async () => {
6168
await expect(hasJSX('/nonexistent/file.js')).rejects.toThrow('File not found');
@@ -103,10 +110,10 @@ describe('hasJSX API', () => {
103110

104111
describe('Error handling', () => {
105112
it('throws TypeError for non-string input', () => {
106-
expect(() => hasJSXInString(null)).toThrow(TypeError);
107-
expect(() => hasJSXInString(undefined)).toThrow(TypeError);
108-
expect(() => hasJSXInString(123)).toThrow(TypeError);
109-
expect(() => hasJSXInString({})).toThrow(TypeError);
113+
expect(() => hasJSXInString(null as unknown as string)).toThrow(TypeError);
114+
expect(() => hasJSXInString(undefined as unknown as string)).toThrow(TypeError);
115+
expect(() => hasJSXInString(123 as unknown as string)).toThrow(TypeError);
116+
expect(() => hasJSXInString({} as unknown as string)).toThrow(TypeError);
110117
});
111118

112119
it('returns false for empty string', () => {

0 commit comments

Comments
 (0)