refactor(fs): split filesystem seam into provider ctx.fs + policy ctx.fileContext
Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage provider seam (resolve/stat/readText/streamText/writeText/editText with branded FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new dsh-file-context package owns the model-facing policy (read windowing, observed-state, write/edit freshness) as the concrete ctx.fileContext service. Authorization is now freshness-based rather than full/partial view: a windowed read records the file version and authorizes a later edit when the file is unchanged, removing the dead-end where reading lines 100-150 of a large file could not edit line 120. editText stays a provider primitive so version guard + literal match + atomic rewrite remain one critical section, and the stale check runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects fileContext, never reaching around to ctx.fs (the no-bypass contract).
This commit is contained in:
@@ -1,24 +1,27 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution,
|
||||
* fast/streaming reads, pagination/caps, binary rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling.
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
|
||||
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
|
||||
* policy and lives in `dsh-file-context`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
formatReadBody,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextPage,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
@@ -28,8 +31,13 @@ afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const READ_ALL = { offset: 1, limit: 2000 }
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path })
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
|
||||
|
||||
async function collect(chunks: AsyncIterable<string>): Promise<string> {
|
||||
let out = ''
|
||||
for await (const chunk of chunks) out += chunk
|
||||
return out
|
||||
}
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
@@ -37,11 +45,10 @@ describe('resolveLocalTarget', () => {
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(file))
|
||||
expect(target.targetKey).toBe(await realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const { realpath } = await import('node:fs/promises')
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
@@ -67,186 +74,104 @@ describe('resolveLocalTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextPage', () => {
|
||||
it('reads a small file with line numbers and full view', async () => {
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and metadata for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.type).toBe('file')
|
||||
expect(info?.size).toBe(2)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('reports a directory and a non-regular type', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.type).toBe('directory')
|
||||
})
|
||||
|
||||
it('reports a socket/special file as type "other"', async () => {
|
||||
const sockPath = join(dir, 'sock')
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve) => { server.listen(sockPath, () => { resolve() }) })
|
||||
try {
|
||||
expect((await probe(sockPath))?.type).toBe('other')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeText', () => {
|
||||
it('reads a small file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.view).toBe('full')
|
||||
})
|
||||
|
||||
it('paginates with offset/limit and reports a partial view', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree\nfour')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 })
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.view).toBe('partial')
|
||||
expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
|
||||
it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full')
|
||||
expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial')
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const file = join(dir, 'long.txt')
|
||||
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 () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100))
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB')
|
||||
})
|
||||
|
||||
it('strips CRLF so a Windows file reads like LF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1', async () => {
|
||||
const file = join(dir, 'empty.txt')
|
||||
await writeFile(file, '')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL)
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)')
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a binary file (fast path)', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
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' })
|
||||
expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
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' })
|
||||
await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects binary and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the fast path', async () => {
|
||||
it('passes a live (non-aborted) signal through', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('streaming path (forced via a tiny fastPathMaxSize)', () => {
|
||||
const stream = { fastPathMaxSize: 1 }
|
||||
|
||||
it('reads and paginates large files the same way', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream)
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects a binary file on the streaming path', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line without unbounded buffering', async () => {
|
||||
const file = join(dir, 'one-line.txt')
|
||||
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 () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const file = join(dir, 'no-nl.txt')
|
||||
await writeFile(file, 'one\ntwo') // no trailing \n
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling buffer at EOF)', async () => {
|
||||
const file = join(dir, 'nl.txt')
|
||||
await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream)
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through to the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream)
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
it('scans across multiple stream chunks', async () => {
|
||||
// A file well past the default 64 KB stream highWaterMark yields multiple chunks,
|
||||
// exercising the non-first-chunk branch and the line-buffer cap across appends.
|
||||
const file = join(dir, 'multi.txt')
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`)
|
||||
await writeFile(file, lines.join('\n'))
|
||||
const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream)
|
||||
expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true)
|
||||
expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)')
|
||||
expect(result.totalLines).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams a large multi-chunk file correctly', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
|
||||
await writeFile(file, content)
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
|
||||
})
|
||||
|
||||
it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
@@ -259,8 +184,7 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
const info = await stat(file)
|
||||
expect(info.mode & 0o777).toBe(0o640)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -278,7 +202,6 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
// The pre-existing staging dir is intact and the target was not created.
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
@@ -303,9 +226,8 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => {
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target
|
||||
await mkdir(sub)
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
// No leftover staging dirs in the directory.
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -346,16 +268,11 @@ describe('readForEdit + restoreLineEndings', () => {
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file', async () => {
|
||||
const file = join(dir, 'bin')
|
||||
await writeFile(file, Buffer.from([0x00, 0x01]))
|
||||
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('rejects a binary file and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
@@ -365,20 +282,3 @@ describe('readForEdit + restoreLineEndings', () => {
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and info for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.isFile).toBe(true)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('marks a directory as not a regular file', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.isFile).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user