Preserve instruction symlink guard through fs seam

This commit is contained in:
Yichen Jiang
2026-07-06 13:55:20 +08:00
parent 3c42352310
commit 901315d8ab
18 changed files with 258 additions and 25 deletions
+39 -8
View File
@@ -20,7 +20,7 @@
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
@@ -111,6 +111,14 @@ export interface PathInfo {
size: number
}
/** Result of probing a path without following the final symlink component. */
export interface PathLinkInfo {
version: FsVersion
mode: number
type: 'file' | 'directory' | 'symlink' | 'other'
size: number
}
/** One local directory child with a resolved target and cheap metadata. */
export interface LocalDirEntry {
name: string
@@ -165,21 +173,44 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
}
}
/** Probe a path for its version, mode, type, and size. Null if absent. */
export async function probe(absolutePath: string): Promise<PathInfo | null> {
function pathType(info: Stats): PathInfo['type'] {
if (info.isFile()) return 'file'
if (info.isDirectory()) return 'directory'
return 'other'
}
function pathLinkType(info: Stats): PathLinkInfo['type'] {
if (info.isSymbolicLink()) return 'symlink'
return pathType(info)
}
async function probeStats(absolutePath: string, readStats: (path: string) => Promise<Stats>): Promise<Stats | null> {
try {
const info = await stat(absolutePath)
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
return await readStats(absolutePath)
} catch (error: unknown) {
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
// the target is absent; any other stat failure is a real permission/IO fault.
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
// the target is absent; any other metadata failure is a real permission/IO
// fault.
/* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */
if (!isENOENT(error) && !isENOTDIR(error)) throw error
return null
}
}
/** Probe a path for its version, mode, type, and size. Null if absent. */
export async function probe(absolutePath: string): Promise<PathInfo | null> {
const info = await probeStats(absolutePath, stat)
if (!info) return null
return { version: versionOf(info), mode: info.mode & 0o777, type: pathType(info), size: info.size }
}
/** Probe a path without following the final symlink component. Null if absent. */
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
const info = await probeStats(absolutePath, lstat)
if (!info) return null
return { version: versionOf(info), mode: info.mode & 0o777, type: pathLinkType(info), size: info.size }
}
// --- Directory listing ---
function listingIoError(displayPath: string, error: unknown): FsError {
+14 -2
View File
@@ -1,6 +1,6 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the eight
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
@@ -14,6 +14,7 @@
*/
import { Context } from 'cordis'
import { resolve } from 'node:path'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -21,6 +22,7 @@ import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
@@ -30,6 +32,7 @@ import {
listDirectory,
normalizeLineEndings,
probe,
probeNoFollow,
readForEdit,
readTextForDiff,
readWholeText,
@@ -44,6 +47,7 @@ export {
applyLiteralEdit,
listDirectory,
probe,
probeNoFollow,
readForEdit,
readTextForDiff,
readWholeText,
@@ -52,7 +56,7 @@ export {
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo, PathLinkInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
@@ -114,6 +118,14 @@ export class LocalFileSystem extends FileSystem {
return { version: info.version, type: info.type, size: info.size }
}
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path))
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
}
@@ -92,6 +92,29 @@ describe('stat', () => {
})
})
describe('lstat', () => {
it('reports path metadata without following the final symlink component', async () => {
await writeFile(join(dir, 'real.txt'), 'hello')
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
expect((await fs.lstat('real.txt'))?.type).toBe('file')
expect((await fs.lstat('link.txt'))?.type).toBe('symlink')
expect(await fs.lstat('missing.txt')).toBeUndefined()
})
it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => {
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
try {
await writeFile(join(other, 'x.txt'), 'in other')
expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file')
await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
} finally {
await rm(other, { recursive: true, force: true })
}
})
})
describe('readText / streamText', () => {
it('reads whole-file text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
+22
View File
@@ -14,6 +14,7 @@ import {
applyLiteralEdit,
listDirectory,
probe,
probeNoFollow,
readForEdit,
readWholeText,
resolveLocalTarget,
@@ -146,6 +147,27 @@ describe('probe', () => {
})
})
describe('probeNoFollow', () => {
it('reports symlinks without following them', async () => {
const real = join(dir, 'real.txt')
const link = join(dir, 'link.txt')
await writeFile(real, 'hi')
await symlink(real, link)
expect((await probeNoFollow(real))?.type).toBe('file')
const linkInfo = await probeNoFollow(link)
expect(linkInfo?.type).toBe('symlink')
expect(typeof linkInfo?.version).toBe('string')
expect(linkInfo?.size).toBeGreaterThan(0)
})
it('returns null for a missing path or a file-valued ancestor path segment', async () => {
expect(await probeNoFollow(join(dir, 'missing'))).toBeNull()
await writeFile(join(dir, 'afile'), 'i am a file')
expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull()
})
})
describe('listDirectory', () => {
it('lists direct children in stable order without reading content', async () => {
const root = join(dir, 'skills')