fix(dev-infra): reject non-UTF-8 scope paths

This commit is contained in:
Tianyi Cui
2026-07-27 20:54:49 +08:00
parent f1f58bd198
commit b5c8b80839
5 changed files with 100 additions and 14 deletions

View File

@@ -26,7 +26,7 @@ afterEach(() => {
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function git(cwd: string, args: string[], input?: string): string {
function git(cwd: string, args: string[], input?: string | Buffer): string {
return execFileSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
@@ -35,6 +35,14 @@ function git(cwd: string, args: string[], input?: string): string {
}).trim()
}
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
return execFileSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
})
}
function write(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
@@ -187,6 +195,34 @@ describe('change-scope', () => {
expect(repositoryState(root)).toEqual(before)
})
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => {
const { root } = fixture()
const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
const firstPath = Buffer.from([0x80])
const secondPath = Buffer.from([0x81])
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
entry,
firstPath,
Buffer.from([0]),
entry,
secondPath,
Buffer.from([0]),
]))
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
firstPath,
Buffer.from([0]),
secondPath,
Buffer.from([0]),
]))
const output: string[] = []
expect(() => {
writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
expect(output).toEqual([])
})
it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
const { root } = fixture()
git(root, ['branch', 'collision'])

View File

@@ -3,10 +3,11 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { parseArgs, TextDecoder } from 'node:util'
const FORMAT_VERSION = 1
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
interface ChangeScopeReport {
formatVersion: typeof FORMAT_VERSION
@@ -39,6 +40,13 @@ interface GitCommandResult {
error: Error | undefined
}
interface GitBytesCommandResult {
status: number | null
stdout: Buffer
stderr: Buffer
error: Error | undefined
}
interface ChangeScopeOptions {
base: string
head: string
@@ -59,6 +67,19 @@ function executeGit(cwd: string, args: string[]): GitCommandResult {
}
}
function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult {
const result = spawnSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
maxBuffer: MAX_GIT_OUTPUT,
})
return {
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error,
}
}
function failureDetail(result: GitCommandResult): string {
return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
}
@@ -69,6 +90,16 @@ function requireGit(cwd: string, args: string[], context: string): string {
return result.stdout
}
function requireGitBytes(cwd: string, args: string[], context: string): Buffer {
const result = executeGitBytes(cwd, args)
if (result.status !== 0) {
const detail = result.error?.message
?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`)
throw new Error(`${context}: ${detail}`)
}
return result.stdout
}
function parseOptions(args: string[]): ChangeScopeOptions {
const { values } = parseArgs({
args,
@@ -141,12 +172,27 @@ function comparePaths(left: string, right: string): number {
return 0
}
function parsePathSet(output: string): string[] {
return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths)
function parsePathSet(output: Buffer, context: string): string[] {
const paths: string[] = []
let start = 0
let record = 0
for (let end = 0; end < output.length; end += 1) {
if (output[end] !== 0) continue
if (end > start) {
record += 1
try {
paths.push(UTF8_DECODER.decode(output.subarray(start, end)))
} catch {
throw new Error(`${context}: Git path ${record} is not valid UTF-8`)
}
}
start = end + 1
}
return [...new Set(paths)].sort(comparePaths)
}
function diffPaths(root: string, args: string[], context: string): string[] {
return parsePathSet(requireGit(root, [
return parsePathSet(requireGitBytes(root, [
'diff',
'--no-ext-diff',
'--no-textconv',
@@ -156,7 +202,7 @@ function diffPaths(root: string, args: string[], context: string): string[] {
'-z',
...args,
'--',
], context))
], context), context)
}
function stripGitLineTerminator(output: string): string {
@@ -194,11 +240,11 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep
committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
untracked: parsePathSet(requireGit(
untracked: parsePathSet(requireGitBytes(
root,
['ls-files', '--others', '--exclude-standard', '-z', '--'],
'cannot inspect untracked paths',
)),
), 'cannot inspect untracked paths'),
},
}
}