Files
deepseek-harness/packages/fs/fs-local/src/fsio.ts
Tianyi Cui 2b673bd68d fix(fs): preserve Windows DACLs across atomic replacement
Copy an existing target's DACL onto the empty staging file before any content is written, then publish with ReplaceFileW so Windows replacement keeps the target security descriptor instead of inheriting the broader parent policy.

Keep new-file inheritance and POSIX mode behavior unchanged, retain the already-protected temp when a concurrently removed target requires rename fallback, and translate native errors into Node-style codes for the filesystem error boundary.

Add host-independent Win32 binding coverage, native Windows descriptor assertions, package documentation, and a bilingual implemented RFC that supersedes the earlier inheritance-only replacement claim.
2026-07-19 12:38:18 +08:00

619 lines
27 KiB
TypeScript

/**
* Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text,
* streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes
* stage an exclusive owner-only file in a private sibling directory and atomically rename it.
* @module @deepseek-ai/dsh-fs-local/fsio
*/
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
/**
* A path component that is expected to be a directory is a regular file (e.g.
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
* letting a raw Node error escape without the structured `FsError` taxonomy.
*/
function isENOTDIR(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/* v8 ignore stop */
function isPermissionError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
}
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
/**
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
* error taxonomy — the streaming/write paths translate it the same way).
*/
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
try {
return await readFile(absolutePath, signal ? { signal } : {})
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
if (!isAbortError(error)) throw error
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
}
/** Opaque version token from high-resolution identity and freshness metadata. */
function versionOf(info: BigIntStats): FsVersion {
return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
}
/**
* Test seam: lets specs pin the atomic-write temp names (to prove
* exclusive-open behavior without a name race) and observe the staged temp
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override the host platform for native-publication unit coverage. */
platform?: NodeJS.Platform
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Override the Win32 DACL copy boundary. */
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
export interface LocalTarget {
/** Absolute path (symlinks not resolved) — used for display. */
displayPath: string
/** Realpath identity — used as the stable target key and the I/O path. */
targetKey: FsTargetKey
}
/** Result of probing a path: null when it does not exist. */
export interface PathInfo {
version: FsVersion
mode: number
type: 'file' | 'directory' | 'other'
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
type: 'file' | 'directory' | 'other'
target: LocalTarget
version?: FsVersion
size?: number
}
/**
* Resolve a path to its absolute display path and realpath identity. For a missing target,
* realpath the nearest existing ancestor and append the missing suffix, preserving identity
* across symlinked ancestors before and after creation.
* @param cwd - base directory a relative `path` resolves against.
* @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
* @returns the absolute display path plus the realpath-derived stable target key.
*/
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = resolve(cwd, path)
try {
// Prefer the file's own realpath (resolves a symlinked file to its target).
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
} catch (error: unknown) {
// A path component is a file, not a directory (e.g. "afile/child.txt" where
// "afile" is a regular file): the target can neither exist nor be created,
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
}
// File absent: realpath the nearest existing ancestor and re-append the
// missing suffix (the file basename plus any not-yet-created intermediate
// dirs), so the key is stable across creation of those dirs.
const missing = [basename(displayPath)]
let ancestor = dirname(displayPath)
while (true) {
try {
const realAncestor = await realpath(ancestor)
// On Windows, realpath of a regular file succeeds where POSIX returns
// ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
// Stat the ancestor to restore the semantic distinction: a non-directory
// ancestor means the target passes through a file and can never be created.
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
if (process.platform === 'win32') {
const parentInfo = await stat(realAncestor)
if (!parentInfo.isDirectory()) {
throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
}
}
/* v8 ignore stop */
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
if (error instanceof FsError) throw error
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
missing.unshift(basename(ancestor))
ancestor = parent
}
}
}
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
if (info.isFile()) return 'file'
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
if (info.isDirectory()) return 'directory'
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
return 'other'
}
function pathLinkType(info: Stats | BigIntStats): PathLinkInfo['type'] {
if (info.isSymbolicLink()) return 'symlink'
return pathType(info)
}
async function probeStats<T extends Stats | BigIntStats>(
absolutePath: string,
readStats: (path: string) => Promise<T>,
): Promise<T | null> {
try {
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 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.
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
* @returns the metadata, or null when the path — or a parent segment — does not exist.
*/
export async function probe(absolutePath: string): Promise<PathInfo | null> {
const info = await probeStats(absolutePath, path => stat(path, { bigint: true }))
if (!info) return null
return {
version: versionOf(info),
mode: Number(info.mode & 0o777n),
type: pathType(info),
size: Number(info.size),
}
}
/**
* Probe a path without following the final symlink component.
* @param absolutePath - the path entry to inspect with `lstat` semantics.
* @returns path-entry metadata, or null when the entry is absent.
*/
export async function probeNoFollow(absolutePath: string): Promise<PathLinkInfo | null> {
const info = await probeStats(absolutePath, path => lstat(path, { bigint: true }))
if (!info) return null
return {
version: versionOf(info),
mode: Number(info.mode & 0o777n),
type: pathLinkType(info),
size: Number(info.size),
}
}
// --- Directory listing ---
function listingIoError(displayPath: string, error: unknown): FsError {
/* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
if (error instanceof FsError) return error
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> {
const identity = await resolveLocalTarget(parent.targetKey, name)
return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey }
}
/**
* List direct children of a directory in stable name order. Each child includes
* a resolved target plus stat metadata when still available; file contents are
* never read.
* @param target - the resolved directory to list; a missing or non-directory target throws.
* @param signal - aborts the listing, checked between children (`FS_ABORTED`).
* @returns one entry per direct child, sorted by name.
*/
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
throwIfAborted(signal, 'list')
let info: PathInfo | null
try {
info = await probe(target.targetKey)
} catch (error: unknown) {
throw listingIoError(target.displayPath, error)
}
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
let entries: Dirent[]
try {
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
} catch (error: unknown) {
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
throw listingIoError(target.displayPath, error)
}
throwIfAborted(signal, 'list')
const result: LocalDirEntry[] = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
throwIfAborted(signal, 'list')
try {
const childTarget = await resolveListedChildTarget(target, entry.name)
const childInfo = await probe(childTarget.targetKey)
result.push({
name: entry.name,
type: childInfo?.type ?? 'other',
target: childTarget,
...(childInfo ? { version: childInfo.version } : {}),
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
})
} catch (error: unknown) {
throw listingIoError(join(target.displayPath, entry.name), error)
}
throwIfAborted(signal, 'list')
}
return result
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
}
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
function decodeUtf8Stream(
decoder: TextDecoder,
chunk: Uint8Array | undefined,
verb: 'read' | 'edit',
displayPath: string,
): string {
try {
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
throwIfAborted(signal, verb)
let info: Stats
try {
info = await stat(target.targetKey)
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
if (!isENOENT(error)) throw error
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
return info
}
/**
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
* @param target - the resolved file to read.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the full decoded text, byte-for-byte (no normalization).
*/
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
await statRegularFile(target, 'read', signal)
const raw = await readFileAbortable(target.targetKey, 'read', signal)
throwIfAborted(signal, 'read')
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
return decodeUtf8(raw, 'read', target.displayPath)
}
/**
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
* @param target - the resolved file to stream.
* @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
* @returns decoded text chunks in file order; chunk boundaries carry no meaning.
*/
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
await statRegularFile(target, 'read', signal)
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
function scanBinarySample(chunk: Buffer): void {
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
if (sample.includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
sampledBytes += sample.length
}
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
scanBinarySample(chunk)
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
}
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
} catch (error: unknown) {
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
throw error
}
}
// --- Writing ---
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
try {
await rm(stagingDir, { recursive: true, force: true })
} catch (cleanupError: unknown) {
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
}
throw originalError
}
/**
* Atomically replace a file through a private, synced staging file in the same directory.
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
* onto the empty temp before writing and preserves the target descriptor at publication.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
export async function writeFileAtomic(
absolutePath: string,
content: string,
mode: number | undefined,
signal: AbortSignal | undefined,
internals: FsIoInternals = {},
): Promise<void> {
throwIfAborted(signal, 'write')
const directory = dirname(absolutePath)
await mkdir(directory, { recursive: true })
throwIfAborted(signal, 'write')
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
await mkdir(stagingDir, { mode: 0o700 })
stagingCreated = true
await chmod(stagingDir, 0o700)
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath)
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
if (mode !== undefined) await handle.chmod(mode)
await handle.close()
handle = undefined
throwIfAborted(signal, 'write')
if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during
// staging: the temp already carries that target's protected DACL, so rename recreates it.
if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath)
}
} else {
await rename(tempPath, absolutePath)
}
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
if (handle) {
try {
await handle.close()
} catch (closeError: unknown) {
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
}
}
if (!stagingCreated) throw failure
return removeStagingDirOrThrow(stagingDir, failure)
}
}
// --- Editing ---
/** Line ending style detected before LF normalization. */
export type LineEndings = 'LF' | 'CRLF'
/**
* Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
* uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
* @param content - decoded text in whatever line-ending style the file had.
* @returns the text with every `\r\n` pair replaced by `\n`.
*/
function normalizeLineEndings(content: string): string {
return content.replaceAll('\r\n', '\n')
}
function detectLineEndings(raw: string): LineEndings {
const sample = raw.slice(0, 4096)
const crlfCount = sample.split('\r\n').length - 1
const lfCount = sample.split('\n').length - 1 - crlfCount
return crlfCount > lfCount ? 'CRLF' : 'LF'
}
/**
* Convert LF-normalized content back to the line-ending style detected at read
* time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
* first so an already-CRLF sequence is never doubled to `\r\r\n`.
* @param content - the LF-normalized (edited) text.
* @param lineEndings - the original file's style, as detected by {@link readForEdit}.
* @returns the text in the original file's line-ending style.
*/
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
}
function countOccurrences(content: string, needle: string): number {
let count = 0
let index = 0
while (true) {
const found = content.indexOf(needle, index)
if (found === -1) return count
count += 1
index = found + needle.length
}
}
/**
* Read and decode a file for editing: rejects binaries, returns LF-normalized
* content plus the original line-ending style for write-back.
* @param absolutePath - the file to read (typically a target key).
* @param displayPath - the caller-facing path used in error messages.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the LF-normalized content and the detected style to restore on write-back.
*/
export async function readForEdit(
absolutePath: string,
displayPath: string,
signal?: AbortSignal,
): Promise<{ content: string; lineEndings: LineEndings }> {
throwIfAborted(signal, 'edit')
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
throwIfAborted(signal, 'edit')
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
const raw = decodeUtf8(buffer, 'edit', displayPath)
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
}
/**
* Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still
* succeeds and presentation falls back to a whole-file diff.
* @param absolutePath - the file to read (typically a target key); it must exist.
* @param signal - aborts the read (`FS_ABORTED`).
* @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
*/
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
const buffer = await readFileAbortable(absolutePath, 'read', signal)
if (buffer.includes(0)) return null
try {
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
return null
}
}
/**
* Apply a literal replacement to LF-normalized content. Empty or missing search text throws
* `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true.
* @param content - the current file content, already LF-normalized.
* @param oldString - literal text to find; CRLF inside it is normalized to LF before
* matching.
* @param newString - literal replacement text, normalized the same way.
* @param replaceAll - replace every match instead of requiring exactly one.
* @param displayPath - the caller-facing path used in error messages.
* @returns the edited LF-normalized content plus how many occurrences were replaced.
*/
export function applyLiteralEdit(
content: string,
oldString: string,
newString: string,
replaceAll: boolean,
displayPath: string,
): { content: string; replacements: number } {
const oldNorm = normalizeLineEndings(oldString)
if (oldNorm.length === 0) {
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
}
const newNorm = normalizeLineEndings(newString)
const replacements = countOccurrences(content, oldNorm)
if (replacements === 0) {
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
}
if (!replaceAll && replacements > 1) {
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
}
return { content: content.split(oldNorm).join(newNorm), replacements }
}
export { normalizeLineEndings, restoreLineEndings }