Compare commits
5
Commits
c562af8ec3
...
80defc2868
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80defc2868 | ||
|
|
a22e78d0ca | ||
|
|
755b3d425d | ||
|
|
7ce1a42fbe | ||
|
|
99f0efc69c |
@@ -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.
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user