fix(fs-local): reject unsafe text observations

This commit is contained in:
Dudu-0223
2026-06-22 18:51:30 +08:00
parent ac28e1a1d3
commit c7a197fb5f
5 changed files with 133 additions and 40 deletions

View File

@@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { LocalFileSystem, probe } from '@deepseek-ai/dsh-fs-local'
import type { FsExecContext } from '@deepseek-ai/dsh-fs'
let dir: string
@@ -89,6 +89,19 @@ describe('read → write → edit lifecycle', () => {
expect(outcome.view).toBe('partial')
})
it('records an over-long-line read as partial, so write/edit stay blocked', async () => {
await writeFile(join(dir, 'long.txt'), 'x'.repeat(3000))
const owner = exec()
const target = await fs.resolve('long.txt')
const outcome = await fs.read(target, READ_ALL, owner)
expect(outcome.view).toBe('partial')
await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
await expect(
fs.edit(target, { oldString: 'x', newString: 'y', replaceAll: false }, owner),
).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
})
it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => {
await writeFile(join(dir, 'a.txt'), 'a b')
const owner = exec()
@@ -142,6 +155,22 @@ describe('read-before-write policy', () => {
await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec()))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects invalid UTF-8 reads and edits without rewriting the file', async () => {
const path = join(dir, 'invalid-utf8.txt')
const bytes = Buffer.from([0x68, 0xff, 0x69])
await writeFile(path, bytes)
const owner = exec()
const target = await fs.resolve('invalid-utf8.txt')
await expect(fs.read(target, READ_ALL, owner)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
const existing = await probe(target.targetKey)
if (!existing) throw new Error('expected invalid UTF-8 fixture to exist')
await expect(
fs.applyEdit(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version: existing.version }),
).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
expect(await readFile(path)).toEqual(bytes)
})
})
describe('stale-version guard + concurrency (defensive class B)', () => {

View File

@@ -102,6 +102,7 @@ describe('readTextPage', () => {
await writeFile(file, 'x'.repeat(3000))
const result = await readTextPage(localTarget(file), READ_ALL)
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
expect(result.view).toBe('partial')
})
it('caps output bytes and reports truncatedByBytes', async () => {
@@ -141,6 +142,12 @@ describe('readTextPage', () => {
await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('rejects invalid UTF-8 bytes (fast path)', async () => {
const file = join(dir, 'invalid-utf8.txt')
await writeFile(file, Buffer.from([0x68, 0xff, 0x69]))
await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('rejects a missing file and a directory', async () => {
await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
@@ -181,6 +188,13 @@ describe('readTextPage', () => {
await writeFile(file, 'z'.repeat(5000))
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
expect(result.view).toBe('partial')
})
it('rejects invalid UTF-8 bytes on the streaming path', async () => {
const file = join(dir, 'invalid-utf8.txt')
await writeFile(file, Buffer.from([0x68, 0xff, 0x69]))
await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('honors abort on the streaming path', async () => {
@@ -338,6 +352,12 @@ describe('readForEdit + restoreLineEndings', () => {
await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('rejects invalid UTF-8 bytes', async () => {
const file = join(dir, 'invalid-utf8.txt')
await writeFile(file, Buffer.from([0x68, 0xff, 0x69]))
await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('passes a live (non-aborted) signal through the read', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')