-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(core): reject unverifiable validated read inodes #9857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2072e1a
33f2622
407bcdc
6be6d7c
4310865
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ import { | |
| isCacheableReadResult, | ||
| processSingleFileContent, | ||
| } from '../utils/fileUtils.js'; | ||
| import { hasVerifiableInode } from '../utils/file-identity.js'; | ||
| import { getFolderStructure } from '../utils/getFolderStructure.js'; | ||
|
|
||
| /** | ||
|
|
@@ -159,6 +160,18 @@ export async function readManyFiles( | |
| const displayPath = displayPaths?.get(fullPath) ?? fullPath; | ||
| const validatedIdentity = validatedPathIdentities?.get(fullPath); | ||
| if (validatedPathIdentities && !validatedIdentity) continue; | ||
| if (validatedIdentity && !hasVerifiableInode(validatedIdentity.ino)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The cost is tracking: merging with — qwen3.8-max via Qwen Code /review (v0.22.0)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] On inode-0 filesystems (FAT/exFAT, some SMB mounts — the class The user experience is an attachment vanishing: — qwen3.8-max via Qwen Code /review (v0.22.0) |
||
| if (!seenFiles.has(fullPath)) { | ||
| seenFiles.add(fullPath); | ||
| const { contentParts: errorParts, info } = createFileReadErrorResult( | ||
| displayPath, | ||
| 'Validated file identity is unavailable on this filesystem (inode is 0).', | ||
| ); | ||
| contentParts.push(...errorParts); | ||
| files.push(info); | ||
| } | ||
| continue; | ||
| } | ||
| if ( | ||
| validatedIdentity && | ||
| !(await matchesValidatedPathIdentity(fullPath, validatedIdentity)) | ||
|
|
@@ -223,18 +236,7 @@ export async function readManyFiles( | |
| } catch (error) { | ||
| if (signal?.aborted || isAbortError(error)) throw error; | ||
| const errorMessage = getErrorMessage(error); | ||
| readResult = { | ||
| contentParts: [ | ||
| { text: `\nContent from ${displayPath}:\n` }, | ||
| { text: `Error reading ${displayPath}: ${errorMessage}` }, | ||
| ], | ||
| info: { | ||
| filePath: displayPath, | ||
| content: `Error reading ${displayPath}: ${errorMessage}`, | ||
| isDirectory: false, | ||
| error: errorMessage, | ||
| }, | ||
| }; | ||
| readResult = createFileReadErrorResult(displayPath, errorMessage); | ||
| } | ||
| } else { | ||
| try { | ||
|
|
@@ -299,11 +301,7 @@ async function readValidatedTextFileContent( | |
| ); | ||
| try { | ||
| const stats = await source.stat(); | ||
| if ( | ||
| !stats.isFile() || | ||
| stats.dev !== expected.dev || | ||
| stats.ino !== expected.ino | ||
| ) { | ||
| if (!fileStatsMatchValidatedIdentity(stats, expected)) { | ||
| return null; | ||
| } | ||
| return await readFileContent( | ||
|
|
@@ -334,12 +332,30 @@ async function matchesValidatedPathIdentity( | |
| const canonicalPath = await fs.promises.realpath(filePath); | ||
| if (canonicalPath !== filePath) return false; | ||
| const stats = await fs.promises.stat(canonicalPath); | ||
| return stats.dev === expected.dev && stats.ino === expected.ino; | ||
| return statsMatchValidatedIdentity(stats, expected); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function statsMatchValidatedIdentity( | ||
| stats: fs.Stats, | ||
| expected: ReadManyFilesPathIdentity, | ||
| ): boolean { | ||
| return ( | ||
| hasVerifiableInode(stats.ino) && | ||
| stats.dev === expected.dev && | ||
| stats.ino === expected.ino | ||
| ); | ||
| } | ||
|
|
||
| function fileStatsMatchValidatedIdentity( | ||
| stats: fs.Stats, | ||
| expected: ReadManyFilesPathIdentity, | ||
| ): boolean { | ||
| return stats.isFile() && statsMatchValidatedIdentity(stats, expected); | ||
| } | ||
|
|
||
| async function snapshotValidatedFile( | ||
| filePath: string, | ||
| expected: ReadManyFilesPathIdentity, | ||
|
|
@@ -364,11 +380,7 @@ async function snapshotValidatedFile( | |
| ); | ||
| try { | ||
| const stats = await source.stat(); | ||
| if ( | ||
| !stats.isFile() || | ||
| stats.dev !== expected.dev || | ||
| stats.ino !== expected.ino | ||
| ) { | ||
| if (!fileStatsMatchValidatedIdentity(stats, expected)) { | ||
| return undefined; | ||
| } | ||
| if (stats.size > SNAPSHOT_MAX_SIZE_BYTES) { | ||
|
|
@@ -468,6 +480,25 @@ async function readDirectory( | |
| }; | ||
| } | ||
|
|
||
| function createFileReadErrorResult( | ||
| displayPath: string, | ||
| errorMessage: string, | ||
| ): { contentParts: Part[]; info: FileReadInfo } { | ||
|
Comment on lines
+483
to
+486
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new helper duplicates byte-for-byte the error-result block already constructed inline in this same file (~lines 236-247, the Route the catch block through the helper too (keeping the abort rethrow and the readResult = createFileReadErrorResult(displayPath, errorMessage);中文说明[建议] 新 helper 与同一文件中已有的内联错误结果构造(约 236-247 行, 建议把 catch 块也改为调用该 helper(保留前面的 abort 重新抛出和 readResult = createFileReadErrorResult(displayPath, errorMessage);— qwen3.8-max via Qwen Code /review (v0.22.0) |
||
| const content = `Error reading ${displayPath}: ${errorMessage}`; | ||
| return { | ||
| contentParts: [ | ||
| { text: `\nContent from ${displayPath}:\n` }, | ||
| { text: content }, | ||
| ], | ||
| info: { | ||
| filePath: displayPath, | ||
| content, | ||
| isDirectory: false, | ||
| error: errorMessage, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| async function readFileContent( | ||
| config: Config, | ||
| filePath: string, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The two new zero-inode tests paste the same ~28-line
fs.stat/fs.openmocking block verbatim — this test's setup is byte-identical to the one in'surfaces an error when validated inode identity is unverifiable'(lines ~191-214).The cost is maintenance drift, and it is measurable today: these spies target exactly the object production calls (
fs.promises), but they are never invoked, because the new early guard inreadManyFiles.tsfires on the map'sino: 0before any fs access. So one copy can silently lose its precondition with nothing noticing — deleting this test's mocking block in a scratch tree left the whole file green (52/52), including this test'stoHaveLength(1)dedup assertion. Any future edit to the zero-inode precondition (e.g. also mockinglstat, handlingbigintstats) must now be made in two places; if one copy rots, the test blesses the very regression it was written to catch.Extract a shared helper next to
createTestFileand call it from both tests, e.g.:(checked in a scratch tree: with the helper extracted, all 52 tests still pass)
中文说明
两个新的 zero-inode 测试逐字粘贴了同一段约 28 行的
fs.stat/fs.openmock 代码块——本测试的 setup 与'surfaces an error when validated inode identity is unverifiable'(约 191-214 行)中的完全相同。代价是维护上的漂移,而且现在就可以度量:这些 spy 恰好挂在生产代码调用的对象(
fs.promises)上,但从未被调用,因为readManyFiles.ts里新的早期守卫会在任何 fs 访问之前,基于 map 中的ino: 0提前触发。因此其中一个副本即使悄悄丢失前置条件,也不会有任何测试察觉——在临时树中删掉本测试的 mock 代码块后,整个文件仍然 52/52 全绿,包括本测试的toHaveLength(1)去重断言。将来任何对 zero-inode 前置条件的修改(例如同时 mocklstat、处理bigintstats)都必须在两处同步;若其中一个副本腐化,测试反而会放行它本应捕获的回归。在
createTestFile旁提取一个共享 helper 并让两个测试都调用它(示例见上方英文代码块)。已在临时树中验证:提取 helper 后 52 个测试仍全部通过。— qwen3.8-max via Qwen Code /review (v0.22.0)