mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
1024 lines
41 KiB
TypeScript
1024 lines
41 KiB
TypeScript
/**
|
|
* In-memory filesystem behind the worker's `node:fs` proxy. Contents come from
|
|
* the build-time image (see {@link loadVfsImage}); this remains the synchronous
|
|
* authority when an asynchronous durable sink mirrors selected subtrees.
|
|
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory
|
|
*/
|
|
import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts'
|
|
import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts'
|
|
import { parseTar } from './tar.ts'
|
|
import type {
|
|
Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, VfsOpenFile,
|
|
VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions,
|
|
} from './types.ts'
|
|
|
|
const decoder = new TextDecoder()
|
|
const encoder = new TextEncoder()
|
|
|
|
interface FileNode {
|
|
bytes: Uint8Array
|
|
mtimeMs: number
|
|
/** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */
|
|
mode: number
|
|
/** Stable identity shared by hard links and retained by open descriptors. */
|
|
identity?: bigint
|
|
/** One path normally, a Set only for hard links, or undefined after the final unlink. */
|
|
paths: string | Set<string> | undefined
|
|
}
|
|
|
|
/** Creation default for files, Node's `0o666` under the classic `022` umask. */
|
|
const DEFAULT_FILE_MODE = 0o644
|
|
|
|
/** Creation default for directories, Node's `0o777` under the classic `022` umask. */
|
|
const DEFAULT_DIRECTORY_MODE = 0o755
|
|
|
|
function fail(code: string, syscall: string, path: string, detail?: string): never {
|
|
const error = new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`) as VfsError
|
|
error.code = code
|
|
error.path = path
|
|
error.syscall = syscall
|
|
throw error
|
|
}
|
|
|
|
function encodingOf(options: VfsReadOptions): VfsEncoding | undefined {
|
|
if (options === null || options === undefined) return undefined
|
|
if (typeof options === 'string') return options
|
|
return options.encoding ?? undefined
|
|
}
|
|
|
|
// Permission bits are entry state: creation takes the caller's mode (or the
|
|
// umask-free default), `chmod` changes it, and both stat shapes report the
|
|
// stored value — the round-trip consumers like dsh-credentials-local's
|
|
// owner-only check rely on. The bits are never enforced: a single-owner
|
|
// filesystem reads and writes as its owner regardless, like root.
|
|
function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsStats {
|
|
return {
|
|
size,
|
|
ino: Number(ino),
|
|
mtimeMs,
|
|
ctimeMs: mtimeMs,
|
|
atimeMs: mtimeMs,
|
|
birthtimeMs: mtimeMs,
|
|
mtime: new Date(mtimeMs),
|
|
mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777),
|
|
isFile: () => !directory,
|
|
isDirectory: () => directory,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same entry as {@link statsOf}, in the BigInt shape.
|
|
*
|
|
* Timestamps carry millisecond resolution scaled to nanoseconds, which is what
|
|
* the underlying `mtimeMs` holds; the VFS keeps that value strictly increasing
|
|
* per entry so two writes inside one millisecond still differ.
|
|
* @param size - Byte length; zero for a directory.
|
|
* @param mtimeMs - Modification time the entry carries.
|
|
* @param directory - Whether the entry is a directory.
|
|
* @param ino - Identity of the entry at this path.
|
|
* @param mode - Stored permission bits of the entry.
|
|
* @returns Stats in the shape Node returns under `{ bigint: true }`.
|
|
*/
|
|
function bigIntStatsOf(
|
|
size: number,
|
|
mtimeMs: number,
|
|
directory: boolean,
|
|
ino: bigint,
|
|
mode: number,
|
|
nlink = 1,
|
|
): VfsBigIntStats {
|
|
const milliseconds = BigInt(Math.trunc(mtimeMs))
|
|
const nanoseconds = milliseconds * 1_000_000n
|
|
const time = new Date(mtimeMs)
|
|
return {
|
|
size: BigInt(size),
|
|
mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)),
|
|
dev: 1n,
|
|
ino,
|
|
nlink: BigInt(nlink),
|
|
mtimeMs: milliseconds,
|
|
mtimeNs: nanoseconds,
|
|
ctimeMs: milliseconds,
|
|
ctimeNs: nanoseconds,
|
|
atimeMs: milliseconds,
|
|
atimeNs: nanoseconds,
|
|
birthtimeMs: milliseconds,
|
|
birthtimeNs: nanoseconds,
|
|
mtime: time,
|
|
ctime: time,
|
|
atime: time,
|
|
birthtime: time,
|
|
isFile: () => !directory,
|
|
isDirectory: () => directory,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
}
|
|
}
|
|
|
|
interface OpenMode {
|
|
readonly readable: boolean
|
|
readonly writable: boolean
|
|
readonly append: boolean
|
|
readonly create: boolean
|
|
readonly truncate: boolean
|
|
readonly exclusive: boolean
|
|
}
|
|
|
|
/** Parse the Node string flags supported by the compatibility filesystem. */
|
|
function openMode(flags: string): OpenMode {
|
|
const base = flags[0]
|
|
const suffix = flags.slice(1).split('')
|
|
const validSuffix = suffix.every(flag => flag === '+' || flag === 'x' || flag === 's')
|
|
const uniqueSuffix = new Set(suffix).size === suffix.length
|
|
if ((base !== 'r' && base !== 'w' && base !== 'a') || !validSuffix || !uniqueSuffix
|
|
|| base === 'r' && flags.includes('x')) {
|
|
const error = new TypeError(`The argument 'flags' is invalid. Received '${flags}'`) as TypeError & { code: string }
|
|
error.code = 'ERR_INVALID_ARG_VALUE'
|
|
throw error
|
|
}
|
|
return {
|
|
readable: base === 'r' || flags.includes('+'),
|
|
writable: base !== 'r' || flags.includes('+'),
|
|
append: base === 'a',
|
|
create: base === 'w' || base === 'a',
|
|
truncate: base === 'w',
|
|
exclusive: flags.includes('x'),
|
|
}
|
|
}
|
|
|
|
/** Resize bytes exactly, preserving the prefix and zero-filling growth. */
|
|
function resize(bytes: Uint8Array, length: number): Uint8Array {
|
|
if (!Number.isSafeInteger(length) || length < 0) {
|
|
const error = new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`) as RangeError & { code: string }
|
|
error.code = 'ERR_OUT_OF_RANGE'
|
|
throw error
|
|
}
|
|
const resized = new Uint8Array(length)
|
|
resized.set(bytes.subarray(0, length))
|
|
return resized
|
|
}
|
|
|
|
/** Construction inputs for {@link MemoryVfs}. */
|
|
export interface MemoryVfsOptions {
|
|
/** Durable write-behind observer; absent leaves the filesystem ephemeral. */
|
|
readonly sink?: VfsMutationSink
|
|
}
|
|
|
|
/**
|
|
* Filesystem held in two maps: one for file bytes, one for directories.
|
|
* Every path is normalized to an absolute POSIX path without a trailing
|
|
* separator, so callers may pass either form.
|
|
*/
|
|
export class MemoryVfs implements Vfs {
|
|
private readonly files = new Map<string, FileNode>()
|
|
private readonly directories = new Set<string>([SEP])
|
|
/** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */
|
|
private readonly directoryModes = new Map<string, number>()
|
|
/** Directory mtimes advance when their immediate entry set changes. */
|
|
private readonly directoryMtimes = new Map<string, number>()
|
|
private readonly mutationListeners = new Set<VfsMutationListener>()
|
|
private readonly sink: VfsMutationSink | undefined
|
|
private temporaries = 0
|
|
// Directories retain path identities. File identities live on FileNode so
|
|
// descriptors, renames, and hard links continue to address the same file.
|
|
private readonly identities = new Map<string, bigint>()
|
|
private lastIdentity = 0n
|
|
|
|
/**
|
|
* Build the synchronous filesystem authority.
|
|
* @param options - Optional durable write-behind sink.
|
|
*/
|
|
constructor(options: MemoryVfsOptions = {}) {
|
|
this.sink = options.sink
|
|
}
|
|
|
|
/**
|
|
* Settle the durable sink without changing in-memory success.
|
|
* @returns A promise that resolves when all recorded mutations are stored.
|
|
*/
|
|
async flush(): Promise<void> {
|
|
await this.sink?.flush()
|
|
}
|
|
|
|
/**
|
|
* Observe committed runtime mutations. Image seeding is deliberately silent.
|
|
* @param listener - Consumer called after each successful mutation.
|
|
* @returns A disposer that prevents future calls.
|
|
*/
|
|
subscribe(listener: VfsMutationListener): () => void {
|
|
this.mutationListeners.add(listener)
|
|
return () => { this.mutationListeners.delete(listener) }
|
|
}
|
|
|
|
/** Publish after state changes; one faulty observer cannot roll back a write. */
|
|
private publish(mutation: VfsMutation): void {
|
|
const observers: VfsMutationListener[] = [
|
|
...(this.sink === undefined ? [] : [(change: VfsMutation): void => { this.sink?.record(change) }]),
|
|
...this.mutationListeners,
|
|
]
|
|
for (const listener of observers) {
|
|
try {
|
|
listener(mutation)
|
|
} catch (error) {
|
|
console.error('webworker vfs: mutation observer failed', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Promise face mirroring `node:fs/promises` for the methods the roster uses. */
|
|
readonly promises = {
|
|
readFile: async (path: string, options?: VfsReadOptions): Promise<string | Uint8Array> => this.readFileSync(path, options),
|
|
writeFile: async (path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise<void> => {
|
|
this.writeFileSync(path, data, options)
|
|
},
|
|
appendFile: async (path: string, data: string | Uint8Array): Promise<void> => { this.appendFileSync(path, data) },
|
|
mkdir: async (path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> =>
|
|
this.mkdirSync(path, options),
|
|
readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise<string[] & VfsDirent[]> =>
|
|
this.readdirSync(path, options),
|
|
stat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
|
|
lstat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
|
|
realpath: async (path: string): Promise<string> => this.realpathSync(path),
|
|
rename: async (from: string, to: string): Promise<void> => { this.renameSync(from, to) },
|
|
unlink: async (path: string): Promise<void> => { this.unlinkSync(path) },
|
|
rm: async (path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { this.rmSync(path, options) },
|
|
mkdtemp: async (prefix: string): Promise<string> => this.mkdtempSync(prefix),
|
|
link: async (existing: string, next: string): Promise<void> => { this.linkSync(existing, next) },
|
|
truncate: async (path: string, length?: number): Promise<void> => { this.truncateSync(path, length) },
|
|
chmod: async (path: string, mode: number): Promise<void> => { this.chmodSync(path, mode) },
|
|
opendir: async (path: string): Promise<VfsDir> => this.opendir(path),
|
|
open: async (path: string, flags?: string, mode?: number): Promise<VfsFileHandle> => this.open(path, flags, mode),
|
|
/** Resolves for any existing path: the VFS grants read and write to everything it holds. */
|
|
access: async (path: string): Promise<void> => {
|
|
const target = normalize(resolve(path))
|
|
if (!this.files.has(target) && !this.directories.has(target)) fail('ENOENT', 'access', target)
|
|
},
|
|
}
|
|
|
|
/** @returns Absolute path with no trailing separator. */
|
|
private key(path: string): string {
|
|
const absolute = normalize(resolve(path))
|
|
return absolute.length > 1 && absolute.endsWith(SEP) ? absolute.slice(0, -1) : absolute
|
|
}
|
|
|
|
/**
|
|
* Read a file.
|
|
* @param path - File path.
|
|
* @param options - `'utf8'` or `{encoding}` for text; omitted for bytes.
|
|
* @returns Text or a copy-free view of the stored bytes.
|
|
*/
|
|
readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array {
|
|
const target = this.key(path)
|
|
const node = this.files.get(target)
|
|
if (node === undefined) {
|
|
if (this.directories.has(target)) fail('EISDIR', 'read', target)
|
|
fail('ENOENT', 'open', target)
|
|
}
|
|
return encodingOf(options) === undefined ? node.bytes : decoder.decode(node.bytes)
|
|
}
|
|
|
|
/**
|
|
* Report whether a path exists.
|
|
* @param path - Path to test.
|
|
* @returns True for files and directories.
|
|
*/
|
|
existsSync(path: string): boolean {
|
|
const target = this.key(path)
|
|
return this.files.has(target) || this.directories.has(target)
|
|
}
|
|
|
|
/**
|
|
* Stat a path.
|
|
* @param path - Path to stat.
|
|
* @param options - `bigint` selects the BigInt stats Node returns for it.
|
|
* @returns Stats for the file or directory.
|
|
*/
|
|
statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
|
|
const target = this.key(path)
|
|
const node = this.files.get(target)
|
|
const [size, mtimeMs, directory, mode] = node !== undefined
|
|
? [node.bytes.length, node.mtimeMs, false, node.mode] as const
|
|
: this.directories.has(target)
|
|
? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const
|
|
: fail('ENOENT', 'stat', target)
|
|
const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node)
|
|
return options?.bigint === true
|
|
? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.fileLinkCount(node))
|
|
: statsOf(size, mtimeMs, directory, identity, mode)
|
|
}
|
|
|
|
/** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
|
|
private plainStats(path: string): VfsStats {
|
|
return this.statSync(path) as VfsStats
|
|
}
|
|
|
|
/** @returns The stable identity of an existing path, assigning one on first observation. */
|
|
private identityOf(target: string): bigint {
|
|
const existing = this.identities.get(target)
|
|
if (existing !== undefined) return existing
|
|
this.lastIdentity += 1n
|
|
this.identities.set(target, this.lastIdentity)
|
|
return this.lastIdentity
|
|
}
|
|
|
|
/** @returns The inode-like identity retained by a file node across names. */
|
|
private identityOfFile(node: FileNode): bigint {
|
|
if (node.identity !== undefined) return node.identity
|
|
this.lastIdentity += 1n
|
|
node.identity = this.lastIdentity
|
|
return node.identity
|
|
}
|
|
|
|
/** @returns The number of names currently linked to one file node. */
|
|
private fileLinkCount(node: FileNode): number {
|
|
return typeof node.paths === 'string' ? 1 : node.paths?.size ?? 0
|
|
}
|
|
|
|
/** Add one map name, promoting the rare hard-link case to a Set. */
|
|
private addFilePath(node: FileNode, path: string): void {
|
|
if (node.paths === undefined) {
|
|
node.paths = path
|
|
} else if (typeof node.paths === 'string') {
|
|
node.paths = new Set([node.paths, path])
|
|
} else {
|
|
node.paths.add(path)
|
|
}
|
|
}
|
|
|
|
/** Remove one map name, collapsing a remaining single link back to a string. */
|
|
private removeFilePath(node: FileNode, path: string): void {
|
|
if (typeof node.paths === 'string') {
|
|
node.paths = undefined
|
|
return
|
|
}
|
|
if (node.paths === undefined) return
|
|
node.paths.delete(path)
|
|
if (node.paths.size === 1) {
|
|
const [remaining] = node.paths
|
|
node.paths = remaining
|
|
}
|
|
}
|
|
|
|
/** Set one file-map entry while maintaining both nodes' reverse path indexes. */
|
|
private setFile(path: string, node: FileNode): void {
|
|
const previous = this.files.get(path)
|
|
if (previous === node) return
|
|
if (previous !== undefined) this.removeFilePath(previous, path)
|
|
this.files.set(path, node)
|
|
this.addFilePath(node, path)
|
|
}
|
|
|
|
/** Delete one file-map entry while retaining an unlinked node held by a descriptor. */
|
|
private deleteFile(path: string): FileNode | undefined {
|
|
const node = this.files.get(path)
|
|
if (node === undefined) return undefined
|
|
this.files.delete(path)
|
|
this.removeFilePath(node, path)
|
|
return node
|
|
}
|
|
|
|
/** Publish one linked name after a content or metadata write. */
|
|
private publishFilePath(node: FileNode, path: string, appendedFrom?: number): void {
|
|
this.publish({
|
|
kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false,
|
|
...appendedFrom === undefined ? {} : { appendedFrom },
|
|
})
|
|
}
|
|
|
|
/** Publish a content or metadata write for every hard link to one node. */
|
|
private publishFile(node: FileNode, appendedFrom?: number): void {
|
|
if (typeof node.paths === 'string') {
|
|
this.publishFilePath(node, node.paths, appendedFrom)
|
|
return
|
|
}
|
|
if (node.paths === undefined) return
|
|
for (const path of node.paths) this.publishFilePath(node, path, appendedFrom)
|
|
}
|
|
|
|
/** Replace bytes on one file identity and notify all linked paths. */
|
|
private replaceFile(node: FileNode, bytes: Uint8Array, appendedFrom?: number): void {
|
|
node.bytes = bytes
|
|
node.mtimeMs = this.touchNode(node)
|
|
this.publishFile(node, appendedFrom)
|
|
}
|
|
|
|
/** Write at one offset, zero-filling any gap. */
|
|
private writeFileNode(node: FileNode, position: number, data: Uint8Array): number {
|
|
const offset = Math.max(0, position)
|
|
const previousLength = node.bytes.length
|
|
const bytes = new Uint8Array(Math.max(previousLength, offset + data.length))
|
|
bytes.set(node.bytes)
|
|
bytes.set(data, offset)
|
|
this.replaceFile(node, bytes, offset === previousLength ? previousLength : undefined)
|
|
return data.length
|
|
}
|
|
|
|
/** Resize one file identity and notify all linked paths. */
|
|
private truncateFile(node: FileNode, length: number): void {
|
|
this.replaceFile(node, resize(node.bytes, length))
|
|
}
|
|
|
|
/** @returns Plain stats for an open file, including after its last name is removed. */
|
|
private fileStats(node: FileNode): VfsStats {
|
|
return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode)
|
|
}
|
|
|
|
/** Forget removed directory identities, so recreated paths report new ones. */
|
|
private forgetIdentity(target: string): void {
|
|
this.identities.delete(target)
|
|
const prefix = `${target}${SEP}`
|
|
for (const known of [...this.identities.keys()]) {
|
|
if (known.startsWith(prefix)) this.identities.delete(known)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Modification time for a write, strictly after the entry's previous one.
|
|
*
|
|
* The clock has millisecond resolution and these writes are in memory, so two
|
|
* revisions of one file routinely land in the same millisecond. The filesystem
|
|
* service's stale-write guard compares timestamps, so an equal one would let a
|
|
* stale overwrite through.
|
|
* @param target - Normalized path being written.
|
|
* @returns Now, or one millisecond past the entry's current time.
|
|
*/
|
|
private touch(target: string): number {
|
|
return this.touchNode(this.files.get(target))
|
|
}
|
|
|
|
/** @returns A modification time strictly newer than one file node's current value. */
|
|
private touchNode(node?: FileNode): number {
|
|
const previous = node?.mtimeMs
|
|
const now = Date.now()
|
|
return previous === undefined ? now : Math.max(now, previous + 1)
|
|
}
|
|
|
|
/** Advance a directory's mtime after its immediate children change. */
|
|
private touchDirectory(target: string): void {
|
|
const previous = this.directoryMtimes.get(target)
|
|
const now = Date.now()
|
|
this.directoryMtimes.set(target, previous === undefined ? now : Math.max(now, previous + 1))
|
|
}
|
|
|
|
/**
|
|
* List a directory.
|
|
* @param path - Directory path.
|
|
* @param options - `withFileTypes` returns {@link VfsDirent} objects instead of names.
|
|
* @returns Immediate entry names, or directory entries.
|
|
*/
|
|
readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] {
|
|
const target = this.key(path)
|
|
if (!this.directories.has(target)) {
|
|
if (this.files.has(target)) fail('ENOTDIR', 'scandir', target)
|
|
fail('ENOENT', 'scandir', target)
|
|
}
|
|
const prefix = target === SEP ? SEP : `${target}${SEP}`
|
|
const names = new Set<string>()
|
|
for (const candidate of [...this.files.keys(), ...this.directories]) {
|
|
if (!candidate.startsWith(prefix) || candidate === target) continue
|
|
const rest = candidate.slice(prefix.length)
|
|
if (rest === '') continue
|
|
const [head = rest] = rest.split(SEP)
|
|
names.add(head)
|
|
}
|
|
const sorted = [...names].sort()
|
|
if (options?.withFileTypes !== true) return sorted as string[] & VfsDirent[]
|
|
return sorted.map(name => this.direntOf(target, name)) as string[] & VfsDirent[]
|
|
}
|
|
|
|
/** @returns Directory entry for one child of `directory`. */
|
|
private direntOf(directory: string, name: string): VfsDirent {
|
|
const stats = this.plainStats(join(directory, name))
|
|
return {
|
|
name,
|
|
parentPath: directory,
|
|
isFile: () => stats.isFile(),
|
|
isDirectory: () => stats.isDirectory(),
|
|
isSymbolicLink: () => false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a path; the VFS has no symlinks, so this only normalizes.
|
|
* @param path - Path to resolve.
|
|
* @returns Absolute path.
|
|
*/
|
|
realpathSync(path: string): string {
|
|
const target = this.key(path)
|
|
if (!this.existsSync(target)) fail('ENOENT', 'realpath', target)
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* Create a directory.
|
|
* @param path - Directory path.
|
|
* @param options - `recursive` creates missing parents.
|
|
* @returns First created path when recursive, otherwise undefined.
|
|
*/
|
|
mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined {
|
|
const target = this.key(path)
|
|
if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
|
|
if (this.directories.has(target)) {
|
|
if (options?.recursive === true) return undefined
|
|
fail('EEXIST', 'mkdir', target)
|
|
}
|
|
const parent = dirname(target)
|
|
if (!this.directories.has(parent)) {
|
|
if (options?.recursive !== true) fail('ENOENT', 'mkdir', target)
|
|
this.mkdirSync(parent, options)
|
|
}
|
|
this.directories.add(target)
|
|
this.touchDirectory(target)
|
|
this.touchDirectory(parent)
|
|
const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 0o777
|
|
if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode)
|
|
this.publish({ kind: 'mkdir', path: target, mode })
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* Write a file, replacing existing contents.
|
|
* @param path - File path; its parent directory must exist.
|
|
* @param data - Text or bytes.
|
|
* @param options - `flag` `wx` refuses an existing file, `a` appends.
|
|
*/
|
|
writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void {
|
|
const target = this.key(path)
|
|
if (this.directories.has(target)) fail('EISDIR', 'open', target)
|
|
if (!this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
|
|
const flag = options?.flag ?? 'w'
|
|
if (flag.startsWith('wx') && this.files.has(target)) fail('EEXIST', 'open', target)
|
|
if (flag.startsWith('a')) { this.appendFileSync(target, data); return }
|
|
// POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps
|
|
// the entry's bits.
|
|
const previous = this.files.get(target)
|
|
const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE)
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
if (previous !== undefined) {
|
|
this.replaceFile(previous, bytes)
|
|
return
|
|
}
|
|
const node: FileNode = { bytes, mtimeMs: this.touch(target), mode, paths: undefined }
|
|
this.setFile(target, node)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true })
|
|
}
|
|
|
|
/**
|
|
* Open a directory; consumers enumerate entries or just prove it is one.
|
|
* @param path - Directory path.
|
|
* @returns Directory handle.
|
|
*/
|
|
opendir(path: string): VfsDir {
|
|
const target = this.key(path)
|
|
const names = this.readdirSync(target)
|
|
let cursor = 0
|
|
const direntOf = (name: string): VfsDirent => this.direntOf(target, name)
|
|
return {
|
|
path: target,
|
|
close: async (): Promise<void> => {},
|
|
read: async (): Promise<{ name: string } | null> => {
|
|
const name = names[cursor]
|
|
cursor += 1
|
|
return name === undefined ? null : direntOf(name)
|
|
},
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const name of names) yield direntOf(name)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a file handle.
|
|
* @param path - File path.
|
|
* @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
|
|
* @param mode - Permission bits applied when the open creates the file.
|
|
* @returns File handle.
|
|
*/
|
|
open(path: string, flags = 'r', mode?: number): VfsFileHandle {
|
|
const target = this.key(path)
|
|
// Durable writers fsync the parent directory by opening it read-only.
|
|
if (this.directories.has(target)) {
|
|
if (!flags.startsWith('r')) fail('EISDIR', 'open', target)
|
|
return {
|
|
write: async (): Promise<{ bytesWritten: number }> => fail('EISDIR', 'write', target),
|
|
writeFile: async (): Promise<void> => fail('EISDIR', 'write', target),
|
|
readFile: async (): Promise<string | Uint8Array> => fail('EISDIR', 'read', target),
|
|
truncate: async (): Promise<void> => fail('EISDIR', 'ftruncate', target),
|
|
...this.handleTail(target),
|
|
}
|
|
}
|
|
const file = this.openFileSync(target, flags, mode)
|
|
let position = 0
|
|
let closed = false
|
|
const current = (syscall: string): VfsOpenFile => {
|
|
if (closed) fail('EBADF', syscall, target)
|
|
return file
|
|
}
|
|
return {
|
|
write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => {
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
const descriptor = current('write')
|
|
const offset = descriptor.append ? descriptor.stat().size : position
|
|
const bytesWritten = descriptor.write(offset, bytes)
|
|
position = offset + bytesWritten
|
|
return { bytesWritten }
|
|
},
|
|
writeFile: async (data: string | Uint8Array): Promise<void> => {
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
const descriptor = current('write')
|
|
const offset = descriptor.append ? descriptor.stat().size : position
|
|
position = offset + descriptor.write(offset, bytes)
|
|
},
|
|
readFile: async (options?: VfsReadOptions): Promise<string | Uint8Array> => {
|
|
const descriptor = current('read')
|
|
const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position))
|
|
position += bytes.length
|
|
return encodingOf(options) === undefined ? bytes : decoder.decode(bytes)
|
|
},
|
|
truncate: async (length = 0): Promise<void> => {
|
|
current('ftruncate').truncate(length)
|
|
},
|
|
stat: async (): Promise<VfsStats> => current('fstat').stat(),
|
|
sync: async (): Promise<void> => { current('fsync'); await this.flush() },
|
|
datasync: async (): Promise<void> => { current('fdatasync'); await this.flush() },
|
|
close: async (): Promise<void> => { closed = true },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open one synchronous descriptor over a stable file identity.
|
|
* @param path - File path.
|
|
* @param flags - Node open flags.
|
|
* @param mode - Permission bits applied only when a file is created.
|
|
* @returns An open file that survives path rename, replacement, and unlink.
|
|
*/
|
|
openFileSync(path: string, flags = 'r', mode?: number): VfsOpenFile {
|
|
const target = this.key(path)
|
|
const access = openMode(flags)
|
|
const existing = this.files.get(target)
|
|
if (this.directories.has(target)) fail('EISDIR', 'open', target)
|
|
if (access.exclusive && existing !== undefined) fail('EEXIST', 'open', target)
|
|
if (!access.create && existing === undefined) fail('ENOENT', 'open', target)
|
|
if (access.create && existing === undefined) {
|
|
this.writeFileSync(target, new Uint8Array(), mode === undefined ? undefined : { mode })
|
|
} else if (access.truncate && existing !== undefined) {
|
|
this.truncateFile(existing, 0)
|
|
}
|
|
const node = this.files.get(target)
|
|
if (node === undefined) fail('ENOENT', 'open', target)
|
|
return {
|
|
readable: access.readable,
|
|
writable: access.writable,
|
|
append: access.append,
|
|
read: (position, length) => {
|
|
if (!access.readable) fail('EBADF', 'read', target)
|
|
return node.bytes.subarray(position, position + length)
|
|
},
|
|
write: (position, data) => {
|
|
if (!access.writable) fail('EBADF', 'write', target)
|
|
return this.writeFileNode(node, access.append ? node.bytes.length : position, data)
|
|
},
|
|
truncate: (length) => {
|
|
if (!access.writable) fail('EINVAL', 'ftruncate', target)
|
|
this.truncateFile(node, length)
|
|
},
|
|
stat: () => this.fileStats(node),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Directory-handle members for metadata, durability, and release.
|
|
* `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem
|
|
* resolves immediately and `close` releases nothing.
|
|
* @param target - Normalized path the handle was opened on.
|
|
* @returns Metadata plus the no-op durability and release calls.
|
|
*/
|
|
private handleTail(target: string): Pick<VfsFileHandle, 'stat' | 'sync' | 'datasync' | 'close'> {
|
|
return {
|
|
stat: async (): Promise<VfsStats> => this.plainStats(target),
|
|
sync: async (): Promise<void> => { await this.flush() },
|
|
datasync: async (): Promise<void> => { await this.flush() },
|
|
close: async (): Promise<void> => {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append to a file, creating it when absent.
|
|
* @param path - File path.
|
|
* @param data - Text or bytes.
|
|
*/
|
|
appendFileSync(path: string, data: string | Uint8Array): void {
|
|
const target = this.key(path)
|
|
const existing = this.files.get(target)
|
|
const addition = typeof data === 'string' ? encoder.encode(data) : data
|
|
if (existing === undefined) { this.writeFileSync(target, addition); return }
|
|
this.writeFileNode(existing, existing.bytes.length, addition)
|
|
}
|
|
|
|
/**
|
|
* Move a file or directory subtree.
|
|
* @param from - Source path.
|
|
* @param to - Destination path.
|
|
*/
|
|
renameSync(from: string, to: string): void {
|
|
const source = this.key(from)
|
|
const destination = this.key(to)
|
|
if (source === destination) return
|
|
const node = this.files.get(source)
|
|
if (node !== undefined) {
|
|
if (this.directories.has(destination)) fail('EISDIR', 'rename', destination)
|
|
if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
|
|
if (this.files.get(destination) === node) return
|
|
this.deleteFile(source)
|
|
this.setFile(destination, node)
|
|
this.forgetIdentity(source)
|
|
this.forgetIdentity(destination)
|
|
this.touchDirectory(dirname(source))
|
|
this.touchDirectory(dirname(destination))
|
|
this.publish({ kind: 'remove', path: source })
|
|
this.publish({ kind: 'write', path: destination, bytes: node.bytes, mode: node.mode, entryChanged: true })
|
|
return
|
|
}
|
|
if (!this.directories.has(source)) fail('ENOENT', 'rename', source)
|
|
if (this.files.has(destination)) fail('ENOTDIR', 'rename', destination)
|
|
if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
|
|
if (this.directories.has(destination)) {
|
|
if (this.readdirSync(destination).length > 0) fail('ENOTEMPTY', 'rename', destination)
|
|
this.directories.delete(destination)
|
|
this.directoryModes.delete(destination)
|
|
this.directoryMtimes.delete(destination)
|
|
}
|
|
const prefix = `${source}${SEP}`
|
|
const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = []
|
|
for (const [candidate, value] of [...this.files]) {
|
|
if (!candidate.startsWith(prefix)) continue
|
|
this.deleteFile(candidate)
|
|
const target = join(destination, candidate.slice(prefix.length))
|
|
this.setFile(target, value)
|
|
movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode })
|
|
}
|
|
const movedDirectories: Array<{ path: string; mode: number }> = []
|
|
for (const candidate of [...this.directories]) {
|
|
if (!candidate.startsWith(prefix) && candidate !== source) continue
|
|
const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length))
|
|
this.directories.delete(candidate)
|
|
this.directories.add(moved)
|
|
const bits = this.directoryModes.get(candidate)
|
|
this.directoryModes.delete(candidate)
|
|
if (bits !== undefined) this.directoryModes.set(moved, bits)
|
|
movedDirectories.push({ path: moved, mode: bits ?? DEFAULT_DIRECTORY_MODE })
|
|
const mtime = this.directoryMtimes.get(candidate)
|
|
this.directoryMtimes.delete(candidate)
|
|
if (mtime !== undefined) this.directoryMtimes.set(moved, mtime)
|
|
}
|
|
this.forgetIdentity(source)
|
|
this.forgetIdentity(destination)
|
|
this.touchDirectory(dirname(source))
|
|
this.touchDirectory(dirname(destination))
|
|
this.publish({ kind: 'remove', path: source })
|
|
for (const directory of movedDirectories) {
|
|
this.publish({ kind: 'mkdir', path: directory.path, mode: directory.mode })
|
|
}
|
|
for (const entry of movedFiles) {
|
|
this.publish({
|
|
kind: 'write', path: entry.path, bytes: entry.bytes, mode: entry.mode, entryChanged: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Give existing bytes a second name.
|
|
*
|
|
* Both names retain one file identity, so writes and metadata changes through
|
|
* either name remain visible through the other until that name is removed.
|
|
* @param existing - Source file path.
|
|
* @param next - Additional path; its parent must exist and it must be free.
|
|
*/
|
|
linkSync(existing: string, next: string): void {
|
|
const source = this.key(existing)
|
|
const target = this.key(next)
|
|
const node = this.files.get(source)
|
|
if (node === undefined) fail('ENOENT', 'link', source)
|
|
if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target)
|
|
if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target)
|
|
this.setFile(target, node)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true })
|
|
}
|
|
|
|
/**
|
|
* Shorten a file.
|
|
* @param path - File path.
|
|
* @param length - Byte length to keep; defaults to zero.
|
|
*/
|
|
truncateSync(path: string, length = 0): void {
|
|
const target = this.key(path)
|
|
const node = this.files.get(target)
|
|
if (node === undefined) fail('ENOENT', 'truncate', target)
|
|
this.truncateFile(node, length)
|
|
}
|
|
|
|
/**
|
|
* Change an entry's permission bits; stat reads back exactly what was set.
|
|
* @param path - File or directory path.
|
|
* @param mode - New permission bits (`0o777` mask).
|
|
*/
|
|
chmodSync(path: string, mode: number): void {
|
|
const target = this.key(path)
|
|
const node = this.files.get(target)
|
|
if (node !== undefined) {
|
|
node.mode = mode & 0o777
|
|
if (typeof node.paths === 'string') {
|
|
this.publish({ kind: 'chmod', path: node.paths, mode: node.mode })
|
|
} else if (node.paths !== undefined) {
|
|
for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode })
|
|
}
|
|
return
|
|
}
|
|
if (this.directories.has(target)) {
|
|
const bits = mode & 0o777
|
|
this.directoryModes.set(target, bits)
|
|
this.publish({ kind: 'chmod', path: target, mode: bits })
|
|
return
|
|
}
|
|
fail('ENOENT', 'chmod', target)
|
|
}
|
|
|
|
/**
|
|
* Remove a file.
|
|
* @param path - File path.
|
|
*/
|
|
unlinkSync(path: string): void {
|
|
const target = this.key(path)
|
|
if (this.deleteFile(target) === undefined) fail('ENOENT', 'unlink', target)
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
}
|
|
|
|
/**
|
|
* Remove a file or directory.
|
|
* @param path - Path to remove.
|
|
* @param options - `recursive` removes subtrees, `force` ignores absence.
|
|
*/
|
|
rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void {
|
|
const target = this.key(path)
|
|
if (this.deleteFile(target) !== undefined) {
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
return
|
|
}
|
|
if (this.directories.has(target)) {
|
|
if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target)
|
|
const prefix = `${target}${SEP}`
|
|
for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate)
|
|
for (const candidate of [...this.directories]) {
|
|
if (!candidate.startsWith(prefix)) continue
|
|
this.directories.delete(candidate)
|
|
this.directoryModes.delete(candidate)
|
|
this.directoryMtimes.delete(candidate)
|
|
}
|
|
this.directories.delete(target)
|
|
this.directoryModes.delete(target)
|
|
this.directoryMtimes.delete(target)
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
return
|
|
}
|
|
if (options?.force !== true) fail('ENOENT', 'rm', target)
|
|
}
|
|
|
|
/**
|
|
* Create a uniquely named directory beside `prefix`, as `fs.mkdtempSync` does.
|
|
* @param prefix - Path prefix; the suffix is appended without a separator.
|
|
* @returns The created directory path.
|
|
*/
|
|
mkdtempSync(prefix: string): string {
|
|
this.temporaries += 1
|
|
const target = `${prefix}${Date.now().toString(36)}${this.temporaries.toString(36)}`
|
|
this.mkdirSync(target, { recursive: true })
|
|
return this.key(target)
|
|
}
|
|
|
|
/**
|
|
* Seed a file and its parent directories, for image loading and tests.
|
|
* @param path - File path.
|
|
* @param data - Text or bytes.
|
|
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
*/
|
|
seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void {
|
|
const target = this.key(path)
|
|
this.seedDirectory(dirname(target))
|
|
this.setFile(target, {
|
|
bytes: typeof data === 'string' ? encoder.encode(data) : data,
|
|
mtimeMs: options.mtimeMs ?? this.touch(target),
|
|
mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777,
|
|
paths: undefined,
|
|
})
|
|
this.touchDirectory(dirname(target))
|
|
}
|
|
|
|
/**
|
|
* Create a directory and its parents.
|
|
* @param path - Directory path.
|
|
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
*/
|
|
seedDirectory(path: string, options: VfsSeedOptions = {}): void {
|
|
const target = this.key(path)
|
|
if (!this.directories.has(target)) {
|
|
const parent = dirname(target)
|
|
if (parent !== target) this.seedDirectory(parent)
|
|
if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
|
|
this.directories.add(target)
|
|
this.directoryMtimes.set(target, options.mtimeMs ?? Date.now())
|
|
this.touchDirectory(parent)
|
|
}
|
|
if (options.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777)
|
|
if (options.mtimeMs !== undefined) this.directoryMtimes.set(target, options.mtimeMs)
|
|
}
|
|
|
|
/**
|
|
* Report what this filesystem holds, for the host's boot diagnostics.
|
|
* @returns File count, directory count, and total byte size.
|
|
*/
|
|
usage(): { files: number; directories: number; bytes: number } {
|
|
let bytes = 0
|
|
for (const node of this.files.values()) bytes += node.bytes.length
|
|
return { files: this.files.size, directories: this.directories.size, bytes }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mount a tar image produced by the build-time collector.
|
|
*
|
|
* Entry names are relative to `root` (`node_modules/...`, `config/cordis.yml`);
|
|
* an absolute entry name is a collector defect and fails loud. File contents
|
|
* stay views into `image` — nothing is copied at mount time.
|
|
* @param image - The ustar archive, as `inflateImage` produces it from the fetched image.
|
|
* @param root - Virtual root the entries mount under.
|
|
* @param vfs - Filesystem to fill; a fresh one by default.
|
|
* @returns The filled filesystem.
|
|
*/
|
|
export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryVfs()): MemoryVfs {
|
|
vfs.seedDirectory(root)
|
|
for (const entry of parseTar(image)) {
|
|
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
|
|
if (relativeName.startsWith(SEP)) {
|
|
throw new Error(`webworker vfs: image entry must be relative to ${root}, received "${entry.name}"`)
|
|
}
|
|
const target = join(root, relativeName)
|
|
if (entry.directory) {
|
|
vfs.seedDirectory(target, { mode: entry.mode })
|
|
continue
|
|
}
|
|
vfs.seed(target, entry.bytes, { mode: entry.mode })
|
|
}
|
|
return vfs
|
|
}
|
|
|
|
/**
|
|
* Apply one ordered data overlay to an already mounted base image.
|
|
*
|
|
* Overlay entries may replace files only under the layout's data directories;
|
|
* module code, configuration, and the lowering manifest cannot be shadowed.
|
|
* Paths containing traversal segments are refused before normalization. Later
|
|
* overlays win for files, while file/directory type conflicts fail loud.
|
|
* @param image - Uncompressed ustar overlay archive.
|
|
* @param root - Virtual root shared with the base image.
|
|
* @param vfs - Mounted filesystem to update.
|
|
* @returns The same filesystem after applying the overlay.
|
|
*/
|
|
export function loadVfsOverlay(image: Uint8Array, root: string, vfs: MemoryVfs): MemoryVfs {
|
|
for (const entry of parseTar(image)) {
|
|
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
|
|
const path = relativeName.endsWith('/') ? relativeName.slice(0, -1) : relativeName
|
|
const segments = path.split('/')
|
|
if (path === '' || relativeName.startsWith(SEP)
|
|
|| segments.some(segment => segment === '' || segment === '.' || segment === '..')
|
|
|| !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? '')) {
|
|
throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join('/ or ')}, received "${entry.name}"`)
|
|
}
|
|
const target = join(root, path)
|
|
if (entry.directory) {
|
|
vfs.seedDirectory(target, { mode: entry.mode })
|
|
continue
|
|
}
|
|
if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) {
|
|
throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`)
|
|
}
|
|
vfs.seed(target, entry.bytes, { mode: entry.mode })
|
|
}
|
|
return vfs
|
|
}
|