refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -1,154 +1,106 @@
|
||||
/**
|
||||
* Host-filesystem source access for the local provider, using Node APIs directly in the
|
||||
* subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
|
||||
* satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
|
||||
* identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
|
||||
* startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
|
||||
* workspace. External result locations are allowed, but an external path can never become a query
|
||||
* source.
|
||||
* @module @deepseek-ai/dsh-lsp-local/host
|
||||
*/
|
||||
/** Filesystem-seam source access for the generic stdio LSP provider. */
|
||||
|
||||
import { constants } from 'node:fs'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
|
||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { throwIfAborted } from './abort.ts'
|
||||
|
||||
/** A validated source: its canonical absolute path and current UTF-8 text. */
|
||||
export interface HostSource {
|
||||
/** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
|
||||
/** A canonical workspace in the filesystem/subprocess execution world. */
|
||||
export interface HostWorkspace {
|
||||
/** Stable filesystem identity used for provider pooling. */
|
||||
readonly target: FsTarget
|
||||
/** Canonical absolute path accepted as a subprocess cwd. */
|
||||
readonly canonicalPath: string
|
||||
/** The file's current text, read as UTF-8. */
|
||||
/** Canonical file URI sent during LSP initialization. */
|
||||
readonly fileUrl: string
|
||||
}
|
||||
|
||||
/** A validated source and the exact URI sent to the language server. */
|
||||
export interface HostSource {
|
||||
/** Canonical file URI in the execution world's platform syntax. */
|
||||
readonly fileUrl: string
|
||||
/** Current complete UTF-8 text. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
|
||||
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
|
||||
* collapse to one instance.
|
||||
* @param workspaceRoot - the caller's workspace root (absolute).
|
||||
* @param signal - optional cancellation observed around each filesystem operation.
|
||||
* @returns the canonical directory path.
|
||||
* @throws Error when the path is missing or not a directory.
|
||||
* Resolve and validate one workspace through `ctx.fs`.
|
||||
* @param fs - filesystem provider sharing the language server's execution world.
|
||||
* @param workspaceRoot - caller-supplied workspace path.
|
||||
* @param signal - optional cancellation around provider operations.
|
||||
* @returns stable identity plus process path and file URI.
|
||||
*/
|
||||
export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
|
||||
export async function canonicalizeWorkspace(
|
||||
fs: FileSystem,
|
||||
workspaceRoot: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostWorkspace> {
|
||||
throwIfAborted(signal)
|
||||
let canonical: string
|
||||
let target: FsTarget
|
||||
try {
|
||||
canonical = await realpath(workspaceRoot)
|
||||
} catch (error) {
|
||||
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
|
||||
target = await fs.resolve(workspaceRoot, signal === undefined ? {} : { signal })
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted(signal)
|
||||
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error })
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
const info = await stat(canonical)
|
||||
const info = await fs.stat(target, signal)
|
||||
throwIfAborted(signal)
|
||||
if (!info.isDirectory()) {
|
||||
if (info?.type !== 'directory') {
|
||||
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
|
||||
}
|
||||
return canonical
|
||||
return {
|
||||
target,
|
||||
canonicalPath: fs.processPath(target),
|
||||
fileUrl: fs.fileUrl(target),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
|
||||
* resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
|
||||
* must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
|
||||
* workspace.
|
||||
* @param filePath - the model-supplied source path (relative or absolute).
|
||||
* @param canonicalWorkspace - the already-canonicalized workspace root.
|
||||
* @param maxDocumentBytes - the largest source this host will open.
|
||||
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
|
||||
* @returns the canonical path and current UTF-8 text.
|
||||
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
|
||||
* Resolve, contain, and atomically read one bounded query source through
|
||||
* `ctx.fs`. The provider's bounded read owns stable-handle and no-follow
|
||||
* mechanics; this layer owns only LSP-facing validation and messages.
|
||||
* @param fs - filesystem provider sharing the server's execution world.
|
||||
* @param filePath - absolute source path or path relative to `workspace`.
|
||||
* @param workspace - already-canonical workspace.
|
||||
* @param maxDocumentBytes - largest complete source accepted by this host.
|
||||
* @param signal - optional cancellation.
|
||||
* @returns canonical file URI and current text.
|
||||
*/
|
||||
export async function readHostSource(
|
||||
fs: FileSystem,
|
||||
filePath: string,
|
||||
canonicalWorkspace: string,
|
||||
workspace: HostWorkspace,
|
||||
maxDocumentBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostSource> {
|
||||
throwIfAborted(signal)
|
||||
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
|
||||
let canonicalPath: string
|
||||
let target: FsTarget
|
||||
try {
|
||||
canonicalPath = await realpath(requested)
|
||||
} catch (error) {
|
||||
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
|
||||
target = await fs.resolve(filePath, {
|
||||
cwd: workspace.canonicalPath,
|
||||
...signal === undefined ? {} : { signal },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted(signal)
|
||||
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error })
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
if (!isInside(canonicalWorkspace, canonicalPath)) {
|
||||
if (!fs.contains(workspace.target, target)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
|
||||
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
|
||||
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
|
||||
// symlink between realpath and open (which would otherwise escape the workspace).
|
||||
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
|
||||
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
||||
let text: string
|
||||
try {
|
||||
text = await fs.readTextBounded(target, maxDocumentBytes, signal)
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted(signal)
|
||||
const info = await handle.stat()
|
||||
throwIfAborted(signal)
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
|
||||
// overflow, so a concurrent grow cannot defeat the memory bound.
|
||||
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
throwIfAborted(signal)
|
||||
return { canonicalPath, text }
|
||||
} finally {
|
||||
await handle.close()
|
||||
throw new Error(`source "${filePath}" could not be opened safely: ${messageOf(error)}`, { cause: error })
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
return {
|
||||
fileUrl: fs.fileUrl(target),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
|
||||
async function readCapped(
|
||||
handle: FileHandle,
|
||||
maxBytes: number,
|
||||
filePath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> {
|
||||
const limit = maxBytes + 1
|
||||
const chunk = Buffer.allocUnsafe(limit)
|
||||
let total = 0
|
||||
for (;;) {
|
||||
throwIfAborted(signal)
|
||||
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
|
||||
throwIfAborted(signal)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`)
|
||||
}
|
||||
}
|
||||
return chunk.subarray(0, total)
|
||||
}
|
||||
|
||||
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
|
||||
function isInside(workspace: string, child: string): boolean {
|
||||
if (child === workspace) return true
|
||||
/* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
|
||||
const base = workspace.endsWith(sep) ? workspace : workspace + sep
|
||||
return child.startsWith(base)
|
||||
}
|
||||
|
||||
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
|
||||
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch {
|
||||
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a message from an unknown thrown value without leaking `any`. */
|
||||
function messageOf(error: unknown): string {
|
||||
/* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user