5 Commits
Author SHA1 Message Date
codingetandClaude 80defc2868 test(drive): declare PROPPATCH over a minimal filesystem unsupported
CI / format (pull_request) Successful in 3m13s
CI / lint (pull_request) Successful in 3m13s
CI / install (pull_request) Successful in 7m47s
CI / typetest (pull_request) Successful in 2m25s
CI / typecheck (pull_request) Successful in 2m36s
CI / node-tests (pull_request) Successful in 2m41s
CI / browser-tests (pull_request) Successful in 4m18s
Opting out skipped the test entirely, leaving the path with no coverage. The
tri-state capability with a forbidden alias keeps the assertions that the call
rejects and the properties are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:01:33 +00:00
codingetandClaude a22e78d0ca docs: record the minimal-filesystem conformance work
CI / format (pull_request) Successful in 3m21s
CI / lint (pull_request) Successful in 3m30s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 7m19s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:54:17 +00:00
codingetandClaude 755b3d425d test(drive,ftp,sftp): run conformance over a minimal backing filesystem
Each package ran the suite against a server backed by a full MemoryVFS, so
the servers' fallback paths were never entered. A second run reduces the
server's filesystem to the required operations while leaving the client under
test unchanged; it fails on #177 without the previous commit. Where a server
answers rather than shims — WebDAV 403 for PROPPATCH, FTP 502 and SFTP
SSH_FX_OP_UNSUPPORTED for rename — the run declares that instead of opting
out, which is the decision #179 revisits.

Fixes #181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:53:54 +00:00
codingetandClaude 7ce1a42fbe fix(drive): serve the requested window on a Range without readFileRange
The fallback branch discarded range.start and streamed the file from byte
zero while the response advertised the requested window, so a client asking
for bytes 5-9 got bytes 0-4 and stored them at the wrong offset.
readFileRangeFallback windows the stream and calls the native operation when
there is one.

Fixes #177

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:53:52 +00:00
codingetandClaude 99f0efc69c feat(vfs): share the optional-stripping helpers in conformance
withoutOptional and unsupportedOptional replace the local copies in the
fallback tests; the protocol packages need the same reduction to put their
servers' fallback paths under the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:53:50 +00:00
8 changed files with 207 additions and 131 deletions
+2
View File
@@ -15,6 +15,8 @@ This file records all work in this repository that was assisted or authored by a
- **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints.
- **`@webnet/vfs` — canonical path helpers**: Codex (GPT-5.6 Luna) added public POSIX VFS path utilities for normalization, resolution, parents, basenames, and joining. The helpers clamp traversal at the VFS root, preserve backslashes and Unicode names, and are covered by focused edge-case tests. MemoryVFS, FsaVFS, FTP, and SFTP now reuse the shared implementation; NodeVFS retains its separate traversal-rejection guard.
- **`@webnet/vfs`, `@webnet/drive`, `@webnet/ftp`, `@webnet/sftp` — conformance over a minimal backing filesystem (issues #181, #177)**: Claude Code (Claude Opus 5) added `withoutOptional` and `unsupportedOptional` to `@webnet/vfs/conformance`, replacing the copies in the fallback tests and the WebDAV tests, and gave each protocol package a second `testAsyncVFSConformance` run whose *server* is handed a filesystem reduced to the required operations. The client under test is unchanged, so the suite's existing assertions — inclusive range ends, byte-for-byte comparisons, copy replacing rather than merging — now apply to the servers' fallback paths, which were never entered when every server was backed by a full `MemoryVFS`. That run reproduces #177 before it is fixed: the WebDAV server's Range fallback discarded `range.start` and streamed from byte zero while advertising the requested window, so a resumed download or a media seek silently stored the wrong bytes at the wrong offset; `readFileRangeFallback` now windows the stream and removes the branch, and the unit test that only asserted a 206 status asserts the body and the framing headers. The three remaining differences the second run exposed are declared rather than shimmed, because each is a legitimate protocol answer from a server that cannot do the work: WebDAV PROPPATCH answers 403 (dead properties cannot be built from the required operations), and FTP RNFR/RNTO and SFTP RENAME answer 502 and `SSH_FX_OP_UNSUPPORTED`, which reach the client as `unsupported`. All three are declared with the tri-state capability rather than skipped, so the suite still checks that the call rejects and changes nothing; the WebDAV run aliases `forbidden` to `unsupported` for that, since 403 is the answer the protocol gives here. A Claude Sonnet 5 review caught that this run originally opted out of `setProps` altogether, which left PROPPATCH over a minimal filesystem with no coverage at all — the gap #181 exists to close. Whether those three should shim instead is the decision #179 exists to make. `@webnet/smb2` has no server over an `AsyncVFS` — its test server is a wire-protocol mock over its own store — so nothing there to reduce.
This project was set up with the assistance of [Claude Code](https://claude.ai/code) (Anthropic). The following were written by Claude Code:
- **`@webnet/http-static` — VFS-backed static HTTP handler**: GPT-5.6 Luna added a new workspace package exposing `createStaticHandler`, with request/context path resolution, prefix and suffix lookup, index files, HTML/JSON directory listings, fallback paths, metadata and conditional responses, HEAD support, and single-byte range streaming. Added focused unit coverage and package build/test/typecheck scaffolding.
+36 -30
View File
@@ -7,7 +7,7 @@ import type { RawDialer, RawTransport } from "@webnet/transport"
import type { StateTransferable } from "@webnet/state-transfer"
import { MemoryVFS } from "@webnet/vfs/memory"
import { VFSError, type AsyncVFS, type VFSErrorCode } from "@webnet/vfs"
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
import { testAsyncVFSConformance, withoutOptional } from "@webnet/vfs/conformance"
import { createDAVHandler } from "./server/handler.js"
import { InMemoryLockStore } from "./server/lock_store.js"
import type { DAVServerOptions } from "./server/types.js"
@@ -55,10 +55,11 @@ async function readAllText(stream: ReadableStream<Uint8Array>): Promise<string>
return new TextDecoder().decode(merged)
}
function makeTestPair(prefix?: string) {
function makeTestPair(opts: { prefix?: string; minimal?: boolean } = {}) {
const { prefix, minimal } = opts
const vfs = new MemoryVFS()
const [listener, dialer] = loopbackListener()
const server = new Server(createDAVHandler(vfs, { prefix }))
const server = new Server(createDAVHandler(minimal ? withoutOptional(vfs) : vfs, { prefix }))
const stopPromise = server.listen(listener)
const { pool } = makeFetch(dialer, { keepAlive: false })
const client = new DAVClient({ dialer: pool, base: `http://localhost${prefix ?? ""}` })
@@ -96,18 +97,6 @@ function makeLockedPair(vfs: MemoryVFS = new MemoryVFS()) {
return { vfs, lockStore, client, fetch, close }
}
/** Wraps a MemoryVFS exposing only the required AsyncVFS methods, no optional ones. */
function minimalVfs(vfs: MemoryVFS): AsyncVFS {
return {
stat: (p) => vfs.stat(p),
readdir: (p) => vfs.readdir(p),
readFile: (p) => vfs.readFile(p),
writeFile: (p, s, sz) => vfs.writeFile(p, s, sz),
delete: (p, r) => vfs.delete(p, r),
mkdir: (p) => vfs.mkdir(p),
}
}
/** VFS that rejects every mutating method with a specific VFSError code. */
function errorVfs(code: VFSErrorCode): AsyncVFS {
return {
@@ -197,7 +186,7 @@ suite("DAV server HTTP", () => {
let stats = 0
let listings = 0
const vfs: AsyncVFS = {
...minimalVfs(base),
...withoutOptional(base),
stat: (path) => {
stats++
return base.stat(path)
@@ -234,7 +223,7 @@ suite("DAV server HTTP", () => {
let stats = 0
let listings = 0
const vfs: AsyncVFS = {
...minimalVfs(base),
...withoutOptional(base),
stat: (path) => {
stats++
return base.stat(path)
@@ -266,7 +255,7 @@ suite("DAV server HTTP", () => {
let stats = 0
let listings = 0
const vfs: AsyncVFS = {
...minimalVfs(base),
...withoutOptional(base),
stat: (path) => {
stats++
return base.stat(path)
@@ -501,17 +490,19 @@ suite("DAV server HTTP", () => {
}
})
test("GET range on VFS without readFileRange falls back to full read", async () => {
test("GET range on VFS without readFileRange serves the requested window", async () => {
const vfs = new MemoryVFS()
await vfs.writeFile("/f.txt", streamOf("abcde"))
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/f.txt", {
method: "GET",
headers: { Range: "bytes=1-2" },
})
assert.equal(res.status, 206)
if (res.hasBody) await res.bytes()
assert.equal(res.getHeader("content-range"), "bytes 1-2/5")
assert.equal(res.getHeader("content-length"), "2")
assert.equal(await res.text(), "bc")
} finally {
await close()
}
@@ -716,7 +707,7 @@ suite("DAV server HTTP", () => {
const statPaths: string[] = []
const copyCalls: [string, string][] = []
const vfs: AsyncVFS = {
...minimalVfs(base),
...withoutOptional(base),
stat: (path) => {
statPaths.push(path)
return base.stat(path)
@@ -745,7 +736,7 @@ suite("DAV server HTTP", () => {
test("fallback COPY preserves an existing destination when the source is missing", async () => {
const vfs = new MemoryVFS()
await vfs.writeFile("/dst.txt", streamOf("keep"))
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/missing.txt", {
method: "COPY",
@@ -766,7 +757,7 @@ suite("DAV server HTTP", () => {
const statPaths: string[] = []
const combinedPaths: string[] = []
const fallback: AsyncVFS = {
...minimalVfs(vfs),
...withoutOptional(vfs),
stat: (path) => {
statPaths.push(path)
return vfs.stat(path)
@@ -833,7 +824,7 @@ suite("DAV server HTTP", () => {
const statPaths: string[] = []
const moveCalls: [string, string][] = []
const vfs: AsyncVFS = {
...minimalVfs(base),
...withoutOptional(base),
stat: (path) => {
statPaths.push(path)
return base.stat(path)
@@ -862,7 +853,7 @@ suite("DAV server HTTP", () => {
test("fallback MOVE preserves an existing destination when the source is missing", async () => {
const vfs = new MemoryVFS()
await vfs.writeFile("/dst.txt", streamOf("keep"))
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/missing.txt", {
method: "MOVE",
@@ -879,7 +870,7 @@ suite("DAV server HTTP", () => {
test("MOVE uses fallback when VFS has no move method", async () => {
const vfs = new MemoryVFS()
await vfs.writeFile("/src.txt", streamOf("content"))
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/src.txt", {
method: "MOVE",
@@ -896,7 +887,7 @@ suite("DAV server HTTP", () => {
test("PROPPATCH on VFS without setProps returns 403", async () => {
const vfs = new MemoryVFS()
await vfs.writeFile("/f.txt", streamOf("x"))
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/f.txt", {
method: "PROPPATCH",
@@ -1994,7 +1985,7 @@ suite("DAV server HTTP", () => {
test("COPY directory to path with missing parent returns 404", async () => {
const vfs = new MemoryVFS()
await vfs.mkdir("/srcdir")
const { fetch, close } = makeHttpPair(minimalVfs(vfs))
const { fetch, close } = makeHttpPair(withoutOptional(vfs))
try {
const res = await fetch("http://localhost/srcdir", {
method: "COPY",
@@ -2019,6 +2010,21 @@ testAsyncVFSConformance({
capabilities: { createdAt: true },
})
// The same client against a server that has only the required operations to work with, so the
// server's fallback paths are held to the same contract as its native ones.
testAsyncVFSConformance({
name: "DAVClient (server over a minimal filesystem)",
create: () => {
const { client, close } = makeTestPair({ minimal: true })
return { vfs: client, close }
},
// Dead properties cannot be built out of the required operations, so PROPPATCH answers 403 as
// WebDAV allows rather than reporting the operation as unsupported; the call still has to reject
// and leave the properties alone, which is what the alias tests.
capabilities: { createdAt: true, setProps: "unsupported" },
errorCodes: { unsupported: ["forbidden"] },
})
suite("DAVClient + server over loopback", () => {
test("a 405 from a method other than GET or PUT is not reported as is-a-directory", async () => {
const [listener, dialer] = loopbackListener()
@@ -2044,7 +2050,7 @@ suite("DAVClient + server over loopback", () => {
})
test("prefix stripping", async () => {
const { client, close } = makeTestPair("/dav")
const { client, close } = makeTestPair({ prefix: "/dav" })
try {
const stat = await client.stat("/")
assert.equal(stat.isDirectory, true)
@@ -1,5 +1,6 @@
import type { Context } from "@webnet/http/server"
import type { AsyncVFS } from "@webnet/vfs"
import { readFileRangeFallback } from "@webnet/vfs/fallback"
import type { DAVServerOptions } from "../types.js"
import { parseRange, formatHttpDate } from "../../common/utils.js"
@@ -32,9 +33,7 @@ export async function handleGet(
return
}
const length = end - range.start + 1n
const stream = vfs.readFileRange
? await vfs.readFileRange(vfsPath, range.start, end)
: await vfs.readFile(vfsPath)
const stream = await readFileRangeFallback(vfs, vfsPath, range.start, end)
ctx.res.setStatus(206, "Partial Content")
ctx.res.setHeader("Content-Range", `bytes ${range.start}-${end}/${stat.size}`)
+14 -1
View File
@@ -7,7 +7,7 @@ import type { RawDialer, RawListener, RawTransport, TlsUpgradeOptions } from "@w
import type { StateTransferable } from "@webnet/state-transfer"
import { MemoryVFS } from "@webnet/vfs/memory"
import { VFSError, type AsyncVFS } from "@webnet/vfs"
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
import { testAsyncVFSConformance, withoutOptional } from "@webnet/vfs/conformance"
import { FTPServer } from "./server/server.js"
import type { FTPServerOptions } from "./server/types.js"
import { FTPClient } from "./client/client.js"
@@ -298,6 +298,19 @@ testAsyncVFSConformance({
capabilities: { etag: false },
})
// The same client against a server that has only the required operations to work with, so the
// server's fallback paths are held to the same contract as its native ones.
testAsyncVFSConformance({
name: "FTPClient (server over a minimal filesystem)",
create: () => {
const { client, close } = makeTestPair({ vfs: withoutOptional(new MemoryVFS()) })
return { vfs: client, close }
},
// RNFR/RNTO answers 502 rather than renaming by hand when the filesystem cannot move, so the
// client reports the operation as unsupported. Whether to shim it instead is issue #179.
capabilities: { etag: false, move: "unsupported" },
})
// -- suites --
suite("FTPClient + FTPServer over loopback", () => {
+46 -24
View File
@@ -3,7 +3,11 @@ import assert from "node:assert/strict"
import { loopbackListener } from "@webnet/transport/loopback"
import { MemoryVFS } from "@webnet/vfs/memory"
import { VFSError, type AsyncVFS } from "@webnet/vfs"
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
import {
testAsyncVFSConformance,
withoutOptional,
type ConformanceCapabilities,
} from "@webnet/vfs/conformance"
import { SFTPClient } from "./client/client.js"
import { SFTPServer } from "./server/server.js"
import type { SFTPServerOptions } from "./server/types.js"
@@ -16,29 +20,47 @@ async function sharedHostKey(): Promise<string> {
return hostKey
}
testAsyncVFSConformance({
name: "SFTPClient",
capabilities: { etag: false },
// SFTP v3's RENAME has no overwrite semantics; the server pre-checks the destination and
// fails the same way (SSH_FX_FAILURE) whether or not the source itself would also conflict,
// so the client can only report "already-exists" for this case.
errorCodes: { "precondition-failed": ["precondition-failed", "already-exists"] },
create: async () => {
const vfs = new MemoryVFS()
const [listener, dialer] = loopbackListener()
const server = new SFTPServer({ vfs, hostKey: await sharedHostKey() })
const stopped = server.listen(listener, { onError: () => {} })
const client = new SFTPClient({ dialer, host: "test", user: "user", password: "pw" })
return {
vfs: client,
close: async () => {
await client.close().catch(() => {})
listener.close()
await stopped.catch(() => {})
},
}
},
})
function conformanceRun(
name: string,
backing: () => AsyncVFS,
capabilities: ConformanceCapabilities = {},
): void {
testAsyncVFSConformance({
name,
capabilities: { etag: false, ...capabilities },
// SFTP v3's RENAME has no overwrite semantics; the server pre-checks the destination and
// fails the same way (SSH_FX_FAILURE) whether or not the source itself would also conflict,
// so the client can only report "already-exists" for this case.
errorCodes: { "precondition-failed": ["precondition-failed", "already-exists"] },
create: async () => {
const vfs = backing()
const [listener, dialer] = loopbackListener()
const server = new SFTPServer({ vfs, hostKey: await sharedHostKey() })
const stopped = server.listen(listener, { onError: () => {} })
const client = new SFTPClient({ dialer, host: "test", user: "user", password: "pw" })
return {
vfs: client,
close: async () => {
await client.close().catch(() => {})
listener.close()
await stopped.catch(() => {})
},
}
},
})
}
conformanceRun("SFTPClient", () => new MemoryVFS())
// The same client against a server that has only the required operations to work with, so the
// server's fallback paths are held to the same contract as its native ones.
// RENAME answers SSH_FX_OP_UNSUPPORTED rather than renaming by hand when the filesystem cannot
// move, so the client reports the operation as unsupported. Whether to shim it instead is #179.
conformanceRun(
"SFTPClient (server over a minimal filesystem)",
() => withoutOptional(new MemoryVFS()),
{ move: "unsupported" },
)
type Harness = {
client: SFTPClient
+6
View File
@@ -6,3 +6,9 @@ export {
type OptionalSupport,
} from "./conformance.js"
export { chunkedStream, failingStream, readAll, readAllText, streamOf } from "./streams.js"
export {
unsupportedOptional,
withoutOptional,
OPTIONAL_METHODS,
type OptionalMethod,
} from "./strip.js"
+59
View File
@@ -0,0 +1,59 @@
import { VFSError, type AsyncVFS } from "../index.js"
export type OptionalMethod = "statAndReaddir" | "readFileRange" | "copy" | "move" | "setProps"
export const OPTIONAL_METHODS: readonly OptionalMethod[] = [
"statAndReaddir",
"readFileRange",
"copy",
"move",
"setProps",
]
function delegating(vfs: AsyncVFS, methods: readonly OptionalMethod[]): AsyncVFS {
const out: AsyncVFS = {
stat: (p) => vfs.stat(p),
readdir: (p) => vfs.readdir(p),
readFile: (p) => vfs.readFile(p),
writeFile: (p, s, size) => vfs.writeFile(p, s, size),
delete: (p, recursive) => vfs.delete(p, recursive),
mkdir: (p) => vfs.mkdir(p),
}
for (const method of methods) {
if (typeof vfs[method] !== "function") continue
Object.defineProperty(out, method, {
value: (...args: unknown[]) =>
(vfs[method] as (...a: unknown[]) => unknown).apply(vfs, args) as Promise<unknown>,
enumerable: true,
writable: true,
})
}
return out
}
/**
* The same filesystem reduced to the required operations, keeping only the named optional ones.
*
* Servers and other callers are routinely handed a backing filesystem simpler than the one their
* tests use, so this is what puts their fallback paths — rather than the native branch — under the
* conformance suite.
*/
export function withoutOptional(vfs: AsyncVFS, keep: readonly OptionalMethod[] = []): AsyncVFS {
return delegating(vfs, keep)
}
/**
* The same filesystem where the named optional operations are present but reject every call with
* `VFSError("unsupported")`, and the rest are delegated.
*/
export function unsupportedOptional(vfs: AsyncVFS, methods: readonly OptionalMethod[]): AsyncVFS {
const out = delegating(vfs, OPTIONAL_METHODS)
for (const method of methods) {
Object.defineProperty(out, method, {
value: () => Promise.reject(new VFSError("unsupported")),
enumerable: true,
writable: true,
})
}
return out
}
+42 -73
View File
@@ -2,7 +2,12 @@ import { suite, test } from "node:test"
import assert from "node:assert/strict"
import { VFSError, type AsyncVFS, type VFSErrorCode } from "../index.js"
import { MemoryVFS } from "../memory/index.js"
import { testAsyncVFSConformance } from "../conformance/index.js"
import {
OPTIONAL_METHODS,
testAsyncVFSConformance,
unsupportedOptional,
withoutOptional,
} from "../conformance/index.js"
import { readAll, readAllText, streamOf } from "../conformance/streams.js"
import {
copyFallback,
@@ -12,49 +17,6 @@ import {
withFallbacks,
} from "./index.js"
type OptionalMethod = "statAndReaddir" | "readFileRange" | "copy" | "move" | "setProps"
const OPTIONAL: OptionalMethod[] = ["statAndReaddir", "readFileRange", "copy", "move", "setProps"]
function required(vfs: AsyncVFS): AsyncVFS {
return {
stat: (p) => vfs.stat(p),
readdir: (p) => vfs.readdir(p),
readFile: (p) => vfs.readFile(p),
writeFile: (p, s, size) => vfs.writeFile(p, s, size),
delete: (p, recursive) => vfs.delete(p, recursive),
mkdir: (p) => vfs.mkdir(p),
}
}
/** The same filesystem with the named optional operations removed. */
function without(vfs: AsyncVFS, ...methods: OptionalMethod[]): AsyncVFS {
const out = required(vfs)
for (const method of OPTIONAL) {
if (methods.includes(method)) continue
Object.defineProperty(out, method, {
value: (...args: unknown[]) =>
(vfs[method] as (...a: unknown[]) => unknown).apply(vfs, args) as Promise<unknown>,
enumerable: true,
writable: true,
})
}
return out
}
/** The same filesystem where the named optional operations exist but reject as unsupported. */
function rejecting(vfs: AsyncVFS, ...methods: OptionalMethod[]): AsyncVFS {
const out = without(vfs)
for (const method of methods) {
Object.defineProperty(out, method, {
value: () => Promise.reject(new VFSError("unsupported")),
enumerable: true,
writable: true,
})
}
return out
}
// The wrapper is transparent over an implementation that has everything.
testAsyncVFSConformance({
name: "withFallbacks (native)",
@@ -66,7 +28,7 @@ testAsyncVFSConformance({
// cannot be built from the required ones says so rather than going missing.
testAsyncVFSConformance({
name: "withFallbacks (shimmed)",
create: () => ({ vfs: withFallbacks(without(new MemoryVFS(), ...OPTIONAL)) }),
create: () => ({ vfs: withFallbacks(withoutOptional(new MemoryVFS())) }),
capabilities: {
statAndReaddir: true,
readFileRange: true,
@@ -79,12 +41,12 @@ testAsyncVFSConformance({
// Without the wrapper, a stripped filesystem is still conformant on the required operations.
testAsyncVFSConformance({
name: "MemoryVFS (optional operations missing)",
create: () => ({ vfs: without(new MemoryVFS(), ...OPTIONAL) }),
create: () => ({ vfs: withoutOptional(new MemoryVFS()) }),
})
testAsyncVFSConformance({
name: "MemoryVFS (optional operations unsupported)",
create: () => ({ vfs: rejecting(new MemoryVFS(), ...OPTIONAL) }),
create: () => ({ vfs: unsupportedOptional(new MemoryVFS(), OPTIONAL_METHODS) }),
capabilities: {
statAndReaddir: "unsupported",
readFileRange: "unsupported",
@@ -98,7 +60,9 @@ testAsyncVFSConformance({
testAsyncVFSConformance({
name: "withFallbacks (on-unsupported)",
create: () => ({
vfs: withFallbacks(rejecting(new MemoryVFS(), ...OPTIONAL), { policy: "on-unsupported" }),
vfs: withFallbacks(unsupportedOptional(new MemoryVFS(), OPTIONAL_METHODS), {
policy: "on-unsupported",
}),
}),
capabilities: {
statAndReaddir: true,
@@ -119,19 +83,19 @@ async function rejectsWithCode(promise: Promise<unknown>, code: VFSErrorCode): P
suite("withFallbacks", () => {
test("exposes every optional operation", () => {
const vfs = withFallbacks(required(new MemoryVFS()))
for (const method of OPTIONAL) assert.equal(typeof vfs[method], "function")
const vfs = withFallbacks(withoutOptional(new MemoryVFS()))
for (const method of OPTIONAL_METHODS) assert.equal(typeof vfs[method], "function")
})
test("native-only reports a missing operation as unsupported", async () => {
const vfs = withFallbacks(required(new MemoryVFS()), { policy: "native-only" })
const vfs = withFallbacks(withoutOptional(new MemoryVFS()), { policy: "native-only" })
await vfs.writeFile("/f.txt", streamOf("x"))
await rejectsWithCode(vfs.copy("/f.txt", "/g.txt"), "unsupported")
await rejectsWithCode(vfs.statAndReaddir("/"), "unsupported")
})
test("a per-operation policy overrides the default", async () => {
const vfs = withFallbacks(required(new MemoryVFS()), {
const vfs = withFallbacks(withoutOptional(new MemoryVFS()), {
policy: "native-only",
copy: "on-missing",
})
@@ -142,7 +106,7 @@ suite("withFallbacks", () => {
})
test("on-missing does not retry a native rejection", async () => {
const vfs = withFallbacks(rejecting(new MemoryVFS(), "copy"))
const vfs = withFallbacks(unsupportedOptional(new MemoryVFS(), ["copy"]))
await vfs.writeFile("/f.txt", streamOf("x"))
await rejectsWithCode(vfs.copy("/f.txt", "/g.txt"), "unsupported")
await rejectsWithCode(vfs.stat("/g.txt"), "not-found")
@@ -150,7 +114,7 @@ suite("withFallbacks", () => {
test("setProps has no fallback and never invents one", async () => {
for (const policy of ["native-only", "on-missing", "on-unsupported"] as const) {
const vfs = withFallbacks(required(new MemoryVFS()), { policy })
const vfs = withFallbacks(withoutOptional(new MemoryVFS()), { policy })
await vfs.writeFile("/f.txt", streamOf("x"))
await rejectsWithCode(vfs.setProps("/f.txt", { a: "1" }), "unsupported")
}
@@ -170,7 +134,7 @@ suite("fallbacks do not mask failures", () => {
const memory = new MemoryVFS()
await memory.writeFile("/f.txt", streamOf("x"))
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
writeFile: () => assert.fail("the fallback must not run"),
copy: () => Promise.reject(new VFSError(code)),
}
@@ -186,7 +150,7 @@ suite("fallbacks do not mask failures", () => {
await memory.writeFile("/f.txt", streamOf("x"))
const boom = new Error("boom")
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
writeFile: () => assert.fail("the fallback must not run"),
copy: () => Promise.reject(boom),
}
@@ -197,7 +161,7 @@ suite("fallbacks do not mask failures", () => {
const memory = new MemoryVFS()
await memory.writeFile("/f.txt", streamOf("x"))
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
setProps: () => Promise.reject(new VFSError("forbidden")),
}
await rejectsWithCode(
@@ -225,7 +189,10 @@ suite("readFileRange fallback", () => {
},
})
return {
vfs: { ...required(new MemoryVFS()), readFile: () => Promise.resolve(stream) } as AsyncVFS,
vfs: {
...withoutOptional(new MemoryVFS()),
readFile: () => Promise.resolve(stream),
} as AsyncVFS,
sentBytes: () => sent,
wasCancelled: () => cancelled,
}
@@ -243,14 +210,14 @@ suite("readFileRange fallback", () => {
})
test("reports a missing path even when the window is empty", async () => {
const vfs = withFallbacks(required(new MemoryVFS()))
const vfs = withFallbacks(withoutOptional(new MemoryVFS()))
await rejectsWithCode(vfs.readFileRange("/ghost.txt", 5n, 2n), "not-found")
})
test("reports a directory even when the window is empty", async () => {
const memory = new MemoryVFS()
await memory.mkdir("/d")
const vfs = withFallbacks(required(memory))
const vfs = withFallbacks(withoutOptional(memory))
await rejectsWithCode(vfs.readFileRange("/d", 5n, 2n), "is-a-directory")
})
@@ -259,7 +226,7 @@ suite("readFileRange fallback", () => {
test("does not retry a native stream that fails after it was handed over", async () => {
const boom = new Error("mid-stream")
const vfs: AsyncVFS = {
...required(new MemoryVFS()),
...withoutOptional(new MemoryVFS()),
readFile: () => assert.fail("the fallback must not run"),
readFileRange: () =>
Promise.resolve(
@@ -295,7 +262,7 @@ suite("readFileRange fallback", () => {
},
})
const vfs: AsyncVFS = {
...required(new MemoryVFS()),
...withoutOptional(new MemoryVFS()),
readFile: () => Promise.resolve(source),
}
const stream = await readFileRangeFallback(vfs, "/f.bin", 0n, 3n)
@@ -309,7 +276,7 @@ suite("readFileRange fallback", () => {
})
test("rejects negative bounds", () => {
const vfs = withFallbacks(required(new MemoryVFS()))
const vfs = withFallbacks(withoutOptional(new MemoryVFS()))
assert.throws(() => vfs.readFileRange("/f.txt", -1n), RangeError)
assert.throws(() => vfs.readFileRange("/f.txt", 0n, -2n), RangeError)
})
@@ -326,25 +293,25 @@ suite("copy and move fallbacks", () => {
}
test("rejects a destination inside the source", async () => {
const vfs = withFallbacks(required(await tree()))
const vfs = withFallbacks(withoutOptional(await tree()))
await rejectsWithCode(vfs.copy("/a", "/a/b/copy"), "precondition-failed")
})
test("rejects a source inside the destination", async () => {
const vfs = withFallbacks(required(await tree()))
const vfs = withFallbacks(withoutOptional(await tree()))
await rejectsWithCode(vfs.copy("/a/b", "/a", { overwrite: true }), "precondition-failed")
assert.equal(await readAllText(await vfs.readFile("/a/b/g.txt")), "two")
})
test("allows a sibling whose name extends the source", async () => {
const vfs = withFallbacks(required(await tree()))
const vfs = withFallbacks(withoutOptional(await tree()))
await vfs.copy("/a", "/ab")
assert.equal(await readAllText(await vfs.readFile("/ab/b/g.txt")), "two")
})
// Every path lies under the root, so copying it anywhere, or anything onto it, always overlaps.
test("rejects the root as either end", async () => {
const vfs = withFallbacks(required(await tree()))
const vfs = withFallbacks(withoutOptional(await tree()))
await rejectsWithCode(vfs.copy("/", "/dest"), "precondition-failed")
await rejectsWithCode(vfs.copy("/a", "/", { overwrite: true }), "precondition-failed")
assert.equal(await readAllText(await vfs.readFile("/a/f.txt")), "one")
@@ -354,7 +321,7 @@ suite("copy and move fallbacks", () => {
const memory = await tree()
let writes = 0
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
writeFile: (p, s, size) => {
if (++writes > 1) return Promise.reject(new VFSError("forbidden"))
return memory.writeFile(p, s, size)
@@ -368,7 +335,7 @@ suite("copy and move fallbacks", () => {
const memory = await tree()
await memory.mkdir("/dest")
await memory.writeFile("/dest/stale.txt", streamOf("stale"))
const vfs = withFallbacks(required(memory))
const vfs = withFallbacks(withoutOptional(memory))
await vfs.copy("/a", "/dest", { overwrite: true })
assert.deepEqual(
(await vfs.readdir("/dest")).map((e) => e.name).sort(),
@@ -378,7 +345,7 @@ suite("copy and move fallbacks", () => {
})
test("moving a path onto itself changes nothing", async () => {
const vfs = withFallbacks(required(await tree()))
const vfs = withFallbacks(withoutOptional(await tree()))
await vfs.move("/a/f.txt", "/a/./f.txt")
assert.equal(await readAllText(await vfs.readFile("/a/f.txt")), "one")
})
@@ -386,7 +353,7 @@ suite("copy and move fallbacks", () => {
test("keeps the destination when deleting the source fails", async () => {
const memory = await tree()
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
delete: (p, recursive) => {
if (p === "/a") return Promise.reject(new VFSError("locked"))
return memory.delete(p, recursive)
@@ -399,7 +366,9 @@ suite("copy and move fallbacks", () => {
test("carries props onto the copy", async () => {
const memory = await tree()
await memory.setProps("/a/f.txt", { "{webnet:test}k": "v" })
const vfs = withFallbacks(without(memory, "copy", "move"))
const vfs = withFallbacks(
withoutOptional(memory, ["statAndReaddir", "readFileRange", "setProps"]),
)
await vfs.copy("/a", "/copy")
assert.deepEqual((await vfs.stat("/copy/f.txt")).props, { "{webnet:test}k": "v" })
})
@@ -408,7 +377,7 @@ suite("copy and move fallbacks", () => {
const memory = await tree()
await memory.setProps("/a/f.txt", { "{webnet:test}k": "v" })
const vfs: AsyncVFS = {
...required(memory),
...withoutOptional(memory),
setProps: () => Promise.reject(new VFSError("unsupported")),
}
await copyFallback(vfs, "/a", "/copy")