3 Commits
Author SHA1 Message Date
codingetandClaude bd78bf528d test(smb2): run the AsyncVFS conformance suite
CI / lint (pull_request) Successful in 1m33s
CI / format (pull_request) Successful in 1m39s
CI / install (pull_request) Failing after 56m4s
CI / typecheck (pull_request) Skipped
CI / typetest (pull_request) Skipped
CI / node-tests (pull_request) Skipped
CI / browser-tests (pull_request) Skipped
The suite caught two client bugs: delete() accepted the share root, and copy()
ignored opts.overwrite so it always clobbered the destination, and could not
copy directories.

The mock server also mis-called #respond on two missing-parent paths, ignored
CreateOptions.DIRECTORY_FILE / NON_DIRECTORY_FILE, and ignored ReplaceIfExists
on rename, so it never produced the errors a real server would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:24:58 +00:00
codingetandClaude e3af63ac77 test(sftp): run the AsyncVFS conformance suite
The suite caught two bugs. statusToVFSError guessed the VFS error code from
the request verb for SSH_FX_FAILURE replies, discarding the code that
vfsErrorToStatus embeds in the message, so most invalid-operation errors
surfaced as a bare SFTPError. Opening a directory for reading also succeeded,
deferring is-a-directory to the first READ rather than rejecting readFile().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:23:16 +00:00
codingetandClaude dcbb042fe3 test(ftp): run the AsyncVFS conformance suite
The suite caught two client bugs: move() only checked for an existing
destination when { overwrite: false } was passed explicitly, so a default move
silently clobbered it, and it reported already-exists rather than
precondition-failed. replyToVFSError also discarded the server's explicit
"not a directory" / "is a directory" text, reporting not-found instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:22:44 +00:00
8 changed files with 142 additions and 502 deletions
+2 -2
View File
@@ -421,7 +421,7 @@ export class FTPClient implements AsyncVFS {
async move(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void> {
src = resolvePath("/", src)
dest = resolvePath("/", dest)
if (opts?.overwrite === false) {
if (!opts?.overwrite) {
const exists = await this.stat(dest).then(
() => true,
(e) => {
@@ -429,7 +429,7 @@ export class FTPClient implements AsyncVFS {
throw e
},
)
if (exists) throw new VFSError("already-exists", dest)
if (exists) throw new VFSError("precondition-failed", dest)
}
const conn = await this.#control()
const release = await conn.acquire()
+6 -1
View File
@@ -30,7 +30,12 @@ export function vfsErrorToReply(e: VFSError): { code: number; text: string } {
export function replyToVFSError(reply: Reply, path: string, verb?: string): VFSError | FTPError {
const { code, text } = reply
if (code === 550 || code === 551) {
if (verb === "RMD" && /empty/i.test(text)) return new VFSError("not-empty", text)
const normalized = text.toLowerCase().replace(/-/g, " ")
if (verb === "RMD" && /\bnot empty\b/.test(normalized)) return new VFSError("not-empty", text)
if (/\bnot a directory\b/.test(normalized))
return new VFSError("not-a-directory", `${text} (${path})`)
if (/\bis a directory\b/.test(normalized))
return new VFSError("is-a-directory", `${text} (${path})`)
if (/permission|denied|access/i.test(text))
return new VFSError("forbidden", `${text} (${path})`)
return new VFSError("not-found", `${text} (${path})`)
+19 -185
View File
@@ -7,6 +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 { FTPServer } from "./server/server.js"
import type { FTPServerOptions } from "./server/types.js"
import { FTPClient } from "./client/client.js"
@@ -36,17 +37,6 @@ function streamOf(s: string | Uint8Array): ReadableStream<Uint8Array> {
})
}
function chunkedStream(data: Uint8Array, chunkSize: number): ReadableStream<Uint8Array> {
let offset = 0
return new ReadableStream({
pull(c) {
if (offset >= data.length) return c.close()
c.enqueue(data.subarray(offset, offset + chunkSize))
offset += chunkSize
},
})
}
async function readAll(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const chunks: Uint8Array[] = []
const reader = stream.getReader()
@@ -244,6 +234,24 @@ async function seed(vfs: AsyncVFS, files: Record<string, string>) {
}
}
testAsyncVFSConformance({
name: "FTPClient",
create: () => {
const { client, close } = makeTestPair()
return { vfs: client, close }
},
// FTP has no notion of an entity tag; stat() never reports one.
capabilities: { etag: false },
errorCodes: {
// A 550 reply is FTP's catch-all failure code: once the backend's error
// message doesn't literally say "not a directory"/"is a directory" (see
// replyToVFSError), or the DELE of a protected root reports something
// other than "permission"/"denied"/"access", the client can't recover
// the original VFSErrorCode and reports not-found instead.
forbidden: ["forbidden", "not-found"],
},
})
// -- suites --
suite("FTPClient + FTPServer over loopback", () => {
@@ -338,165 +346,6 @@ suite("FTPClient + FTPServer over loopback", () => {
}
})
test("write then read round-trip", async () => {
const { client, close } = makeTestPair()
try {
await client.writeFile("/hello.txt", streamOf("hello ftp"))
assert.equal(await readAllText(await client.readFile("/hello.txt")), "hello ftp")
} finally {
await close()
}
})
test("multi-chunk 1MB round-trip", async () => {
const { client, close } = makeTestPair()
try {
const data = new Uint8Array(1024 * 1024)
for (let i = 0; i < data.length; i++) data[i] = i % 251
await client.writeFile("/big.bin", chunkedStream(data, 4096))
const got = await readAll(await client.readFile("/big.bin"))
assert.equal(got.length, data.length)
assert.deepEqual(got, data)
} finally {
await close()
}
})
test("stat file and directory", async () => {
const { vfs, client, close } = makeTestPair()
try {
await seed(vfs, { "/f.txt": "12345" })
await vfs.mkdir("/dir")
const file = await client.stat("/f.txt")
assert.equal(file.isDirectory, false)
assert.equal(file.size, 5n)
assert.equal(file.name, "f.txt")
assert.equal(file.path, "/f.txt")
assert.ok(file.modifiedAt instanceof Date)
const dir = await client.stat("/dir")
assert.equal(dir.isDirectory, true)
const root = await client.stat("/")
assert.equal(root.isDirectory, true)
} finally {
await close()
}
})
test("stat missing file rejects not-found", async () => {
const { client, close } = makeTestPair()
try {
await assert.rejects(client.stat("/nope"), rejectsVfs("not-found"))
} finally {
await close()
}
})
test("readdir", async () => {
const { vfs, client, close } = makeTestPair()
try {
await vfs.mkdir("/dir")
await seed(vfs, { "/dir/a.txt": "aaa", "/dir/b.txt": "b" })
await vfs.mkdir("/dir/sub")
const entries = await client.readdir("/dir")
const byName = new Map(entries.map((e) => [e.name, e]))
assert.deepEqual([...byName.keys()].sort(), ["a.txt", "b.txt", "sub"])
assert.equal(byName.get("a.txt")!.size, 3n)
assert.equal(byName.get("a.txt")!.isDirectory, false)
assert.equal(byName.get("sub")!.isDirectory, true)
assert.equal(byName.get("b.txt")!.path, "/dir/b.txt")
} finally {
await close()
}
})
test("readdir of missing directory rejects not-found", async () => {
const { client, close } = makeTestPair()
try {
await assert.rejects(client.readdir("/nope"), rejectsVfs("not-found"))
} finally {
await close()
}
})
test("mkdir", async () => {
const { vfs, client, close } = makeTestPair()
try {
await client.mkdir("/made")
assert.equal((await vfs.stat("/made")).isDirectory, true)
await assert.rejects(client.mkdir("/made"), rejectsVfs("already-exists"))
} finally {
await close()
}
})
test("delete file, missing, and directories", async () => {
const { vfs, client, close } = makeTestPair()
try {
await seed(vfs, { "/f.txt": "x" })
await client.delete("/f.txt")
await assert.rejects(vfs.stat("/f.txt"), rejectsVfs("not-found"))
await assert.rejects(client.delete("/nope"), rejectsVfs("not-found"))
await vfs.mkdir("/full")
await seed(vfs, { "/full/a": "a", "/full/b": "b" })
await vfs.mkdir("/full/sub")
await seed(vfs, { "/full/sub/c": "c" })
await assert.rejects(client.delete("/full", false), rejectsVfs("not-empty"))
await client.delete("/full", true)
await assert.rejects(vfs.stat("/full"), rejectsVfs("not-found"))
} finally {
await close()
}
})
test("move", async () => {
const { vfs, client, close } = makeTestPair()
try {
await seed(vfs, { "/a.txt": "content", "/exists.txt": "old" })
await client.move("/a.txt", "/b.txt")
assert.equal(await readAllText(await vfs.readFile("/b.txt")), "content")
await assert.rejects(vfs.stat("/a.txt"), rejectsVfs("not-found"))
await assert.rejects(
client.move("/b.txt", "/exists.txt", { overwrite: false }),
rejectsVfs("already-exists"),
)
await client.move("/b.txt", "/exists.txt")
assert.equal(await readAllText(await vfs.readFile("/exists.txt")), "content")
} finally {
await close()
}
})
test("readFileRange", async () => {
const { vfs, client, close } = makeTestPair()
try {
await seed(vfs, { "/f.txt": "hello world" })
assert.equal(await readAllText(await client.readFileRange("/f.txt", 6n)), "world")
assert.equal(await readAllText(await client.readFileRange("/f.txt", 0n, 4n)), "hello")
assert.equal(await readAllText(await client.readFileRange("/f.txt", 6n, 8n)), "wor")
} finally {
await close()
}
})
test("readFile of missing file rejects before returning a stream", async () => {
const { client, close } = makeTestPair()
try {
await assert.rejects(client.readFile("/nope"), rejectsVfs("not-found"))
} finally {
await close()
}
})
test("writeFile into missing directory rejects not-found", async () => {
const { client, close } = makeTestPair()
try {
await assert.rejects(client.writeFile("/nodir/f.txt", streamOf("x")), rejectsVfs("not-found"))
} finally {
await close()
}
})
test("concurrent operations serialize on one control connection", async () => {
const { vfs, client, close } = makeTestPair()
try {
@@ -528,21 +377,6 @@ suite("FTPClient + FTPServer over loopback", () => {
}
})
test("utf8 filenames round-trip", async () => {
const { client, close } = makeTestPair()
try {
await client.writeFile("/héllo düde.txt", streamOf("unicode"))
const entries = await client.readdir("/")
assert.deepEqual(
entries.map((e) => e.name),
["héllo düde.txt"],
)
assert.equal(await readAllText(await client.readFile("/héllo düde.txt")), "unicode")
} finally {
await close()
}
})
test("authenticate rejects bad logins and serves per-user vfs", async () => {
const vfsA = new MemoryVFS()
const vfsB = new MemoryVFS()
+2 -1
View File
@@ -102,7 +102,8 @@ export class Handlers {
this.#reply.handle(id, this.#handles.add(handle))
return
}
await this.#vfs.stat(path)
const stat = await this.#vfs.stat(path)
if (stat.isDirectory) throw new VFSError("is-a-directory")
const handle: ReadHandle = {
kind: "read",
path,
+25 -154
View File
@@ -3,6 +3,7 @@ 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 { SFTPClient } from "./client/client.js"
import { SFTPServer } from "./server/server.js"
import type { SFTPServerOptions } from "./server/types.js"
@@ -15,6 +16,30 @@ 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(() => {})
},
}
},
})
type Harness = {
client: SFTPClient
vfs: AsyncVFS
@@ -97,17 +122,6 @@ async function seed(client: SFTPClient, path: string, text: string): Promise<voi
}
suite("sftp end-to-end", () => {
test("write then read round-trip", async () => {
const h = await setup()
try {
await seed(h.client, "/hello.txt", "hello sftp")
const got = dec.decode(await readAll(await h.client.readFile("/hello.txt")))
assert.equal(got, "hello sftp")
} finally {
await h.close()
}
})
test("4 MiB round-trip exercises windowing and pipelining", async () => {
const h = await setup()
try {
@@ -122,149 +136,6 @@ suite("sftp end-to-end", () => {
}
})
test("readFileRange returns a sub-range and start-to-EOF", async () => {
const h = await setup()
try {
await seed(h.client, "/r.txt", "0123456789")
const mid = dec.decode(await readAll(await h.client.readFileRange("/r.txt", 2n, 5n)))
assert.equal(mid, "2345")
const tail = dec.decode(await readAll(await h.client.readFileRange("/r.txt", 7n)))
assert.equal(tail, "789")
} finally {
await h.close()
}
})
test("stat file, directory, and missing path", async () => {
const h = await setup()
try {
await seed(h.client, "/f.txt", "abc")
await h.client.mkdir("/d")
const f = await h.client.stat("/f.txt")
assert.equal(f.isDirectory, false)
assert.equal(f.size, 3n)
const d = await h.client.stat("/d")
assert.equal(d.isDirectory, true)
await assert.rejects(
h.client.stat("/nope"),
(e) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await h.close()
}
})
test("readdir lists entries and rejects a missing directory", async () => {
const h = await setup()
try {
await h.client.mkdir("/dir")
await seed(h.client, "/dir/a.txt", "a")
await seed(h.client, "/dir/b.txt", "bb")
const names = (await h.client.readdir("/dir")).map((s) => s.name).sort()
assert.deepEqual(names, ["a.txt", "b.txt"])
await assert.rejects(
h.client.readdir("/missing"),
(e) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await h.close()
}
})
test("mkdir, and mkdir of an existing directory", async () => {
const h = await setup()
try {
await h.client.mkdir("/x")
await assert.rejects(
h.client.mkdir("/x"),
(e) => e instanceof VFSError && e.code === "already-exists",
)
} finally {
await h.close()
}
})
test("delete file, missing file, and a recursive tree", async () => {
const h = await setup()
try {
await seed(h.client, "/gone.txt", "x")
await h.client.delete("/gone.txt")
await assert.rejects(
h.client.delete("/gone.txt"),
(e) => e instanceof VFSError && e.code === "not-found",
)
await h.client.mkdir("/tree")
await h.client.mkdir("/tree/sub")
await seed(h.client, "/tree/a.txt", "a")
await seed(h.client, "/tree/sub/b.txt", "b")
await h.client.delete("/tree", true)
await assert.rejects(
h.client.stat("/tree"),
(e) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await h.close()
}
})
test("move renames, and overwrites via posix-rename", async () => {
const h = await setup()
try {
await seed(h.client, "/src.txt", "src")
await h.client.move("/src.txt", "/dst.txt")
assert.equal(dec.decode(await readAll(await h.client.readFile("/dst.txt"))), "src")
await seed(h.client, "/a.txt", "aaa")
await seed(h.client, "/b.txt", "bbb")
await h.client.move("/a.txt", "/b.txt", { overwrite: true })
assert.equal(dec.decode(await readAll(await h.client.readFile("/b.txt"))), "aaa")
} finally {
await h.close()
}
})
test("utf8 filenames round-trip", async () => {
const h = await setup()
try {
await seed(h.client, "/café-☃.txt", "snowman")
const got = dec.decode(await readAll(await h.client.readFile("/café-☃.txt")))
assert.equal(got, "snowman")
const names = (await h.client.readdir("/")).map((s) => s.name)
assert.ok(names.includes("café-☃.txt"))
} finally {
await h.close()
}
})
test("concurrent pipelined operations resolve correctly", async () => {
const h = await setup()
try {
await Promise.all(
Array.from({ length: 8 }, (_, i) => seed(h.client, `/c${i}.txt`, `body-${i}`)),
)
const stats = await Promise.all(
Array.from({ length: 8 }, (_, i) => h.client.stat(`/c${i}.txt`)),
)
assert.deepEqual(
stats.map((s) => Number(s.size)),
Array.from({ length: 8 }, (_, i) => `body-${i}`.length),
)
} finally {
await h.close()
}
})
test("readFile of a missing path rejects before returning a stream", async () => {
const h = await setup()
try {
await assert.rejects(
h.client.readFile("/absent.txt"),
(e) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await h.close()
}
})
test("cancel a read mid-stream then continue on the same client", async () => {
const h = await setup()
try {
+11
View File
@@ -33,6 +33,8 @@ const CODE_BY_VFS_ERROR: Record<VFSErrorCode, number> = {
locked: SSH_FX.FAILURE,
}
const KNOWN_VFS_ERROR_CODES = new Set<string>(Object.keys(CODE_BY_VFS_ERROR))
export function vfsErrorToStatus(e: VFSError): { code: number; message: string } {
const code = CODE_BY_VFS_ERROR[e.code]
if (code === SSH_FX.FAILURE) return { code, message: `${e.code}: ${e.message}` }
@@ -48,6 +50,15 @@ export function statusToVFSError(
if (code === SSH_FX.NO_SUCH_FILE) return new VFSError("not-found", `${message} (${path})`)
if (code === SSH_FX.PERMISSION_DENIED) return new VFSError("forbidden", message)
if (code === SSH_FX.FAILURE) {
// vfsErrorToStatus embeds the original VFSErrorCode as `${code}: ${message}` for the
// FAILURE bucket since it covers several distinct VFS error codes. Recover it when the
// status came from our own server; fall back to verb-based guessing otherwise (e.g. a
// third-party SFTP server, or the RENAME conflict check which never reaches VFS code).
const sep = message.indexOf(": ")
const embedded = sep >= 0 ? message.slice(0, sep) : undefined
if (embedded && KNOWN_VFS_ERROR_CODES.has(embedded)) {
return new VFSError(embedded as VFSErrorCode, message.slice(sep + 2))
}
if (verb === "mkdir") return new VFSError("already-exists", message)
if (verb === "rmdir") return new VFSError("not-empty", message)
if (verb === "rename") return new VFSError("already-exists", message)
+23 -2
View File
@@ -1,6 +1,6 @@
import type { RawDialer, RawTransport } from "@webnet/transport"
import type { StateTransferable } from "@webnet/state-transfer"
import type { AsyncVFS, Stat } from "@webnet/vfs"
import { VFSError, type AsyncVFS, type Stat } from "@webnet/vfs"
import {
Access,
ShareAccess,
@@ -447,6 +447,7 @@ export class SMB2Client implements AsyncVFS, StateTransferable<SMB2TransferState
}
async delete(path: string, recursive?: boolean): Promise<void> {
if (smbPath(path) === "") throw new VFSError("forbidden", "cannot delete root")
const st = await this.stat(path)
if (st.isDirectory && recursive) {
const entries = await this.readdir(path)
@@ -492,7 +493,27 @@ export class SMB2Client implements AsyncVFS, StateTransferable<SMB2TransferState
})
}
async copy(src: string, dest: string): Promise<void> {
async copy(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void> {
const st = await this.stat(src)
if (!opts?.overwrite) {
const exists = await this.stat(dest).then(
() => true,
(e) => {
if (e instanceof VFSError && e.code === "not-found") return false
throw e
},
)
if (exists) throw new VFSError("precondition-failed", dest)
}
if (st.isDirectory) {
await this.mkdir(dest).catch((e) => {
if (!(opts?.overwrite && e instanceof VFSError && e.code === "already-exists")) throw e
})
for (const entry of await this.readdir(src)) {
await this.copy(entry.path, joinPath(dest, entry.name), opts)
}
return
}
const stream = await this.readFile(src)
await this.writeFile(dest, stream)
}
+54 -157
View File
@@ -4,7 +4,7 @@ import { loopbackListener } from "@webnet/transport/loopback"
import { ReadBuffer } from "@webnet/transport/buffer"
import type { RawDialer, RawTransport } from "@webnet/transport"
import type { StateTransferable } from "@webnet/state-transfer"
import { VFSError } from "@webnet/vfs"
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
import { SMB2Client, type SMB2TransferState } from "./client/index.js"
import {
Writer,
@@ -76,16 +76,6 @@ function randomBytes(n: number): Uint8Array {
return out
}
function pseudoRandomBytes(n: number, seed: number): Uint8Array {
const out = new Uint8Array(n)
let s = seed >>> 0
for (let i = 0; i < n; i++) {
s = (s * 1103515245 + 12345) >>> 0
out[i] = (s >>> 16) & 0xff
}
return out
}
function hex(buf: Uint8Array): string {
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("")
}
@@ -588,6 +578,7 @@ class MockSmb2Server {
} else {
if (!this.#fs.nodes.has(parent)) {
return this.#respond(
conn,
header,
transport,
Status.OBJECT_PATH_NOT_FOUND,
@@ -605,6 +596,7 @@ class MockSmb2Server {
} else {
if (!this.#fs.nodes.has(parent)) {
return this.#respond(
conn,
header,
transport,
Status.OBJECT_PATH_NOT_FOUND,
@@ -635,6 +627,25 @@ class MockSmb2Server {
node = existing
}
if ((createOptions & CreateOptions.NON_DIRECTORY_FILE) !== 0 && node.dir) {
return this.#respond(
conn,
header,
transport,
Status.FILE_IS_A_DIRECTORY,
new Writer().u16(9).u16(0).finish(),
)
}
if ((createOptions & CreateOptions.DIRECTORY_FILE) !== 0 && !node.dir) {
return this.#respond(
conn,
header,
transport,
Status.NOT_A_DIRECTORY,
new Writer().u16(9).u16(0).finish(),
)
}
const fileId = randomBytes(16)
this.#handles.set(hex(fileId), {
path,
@@ -920,12 +931,21 @@ class MockSmb2Server {
if (fileInfoClass === 10) {
// FileRenameInformation
const dr = new Reader(data)
dr.u8() // ReplaceIfExists
const replaceIfExists = dr.u8() !== 0
dr.skip(7)
dr.u64() // RootDirectory
const fileNameLength = dr.u32()
const newPath = fromUtf16le(dr.bytes(fileNameLength))
const oldPath = handle.path
if (newPath !== oldPath && this.#fs.nodes.has(newPath) && !replaceIfExists) {
return this.#respond(
conn,
header,
transport,
Status.OBJECT_NAME_COLLISION,
new Writer().u16(2).finish(),
)
}
this.#fs.nodes.delete(oldPath)
this.#fs.nodes.set(newPath, handle.node)
handle.path = newPath
@@ -994,152 +1014,29 @@ function setup(): {
// -- tests --
testAsyncVFSConformance({
name: "SMB2Client",
capabilities: {
createdAt: true,
// SMB2 has no notion of an opaque content etag.
etag: false,
// setProps only maps onto FileBasicInformation timestamps (see setProps
// above); it cannot store arbitrary named properties.
setProps: false,
},
errorCodes: {
// A rename/move collision and a plain name collision are both signalled
// by the server as STATUS_OBJECT_NAME_COLLISION; SMB2 cannot distinguish
// "destination already exists" (precondition-failed) from "already-exists".
"precondition-failed": ["precondition-failed", "already-exists"],
},
create: () => {
const { client, stop } = setup()
return { vfs: client, close: stop }
},
})
suite("smb2 client e2e", () => {
test("writeFile then readFile round-trips exact bytes", async () => {
const { client, stop } = setup()
try {
const data = new TextEncoder().encode("hello, smb2 world!")
await client.writeFile("/greeting.txt", streamOf(data))
const stream = await client.readFile("/greeting.txt")
const read = await readAll(stream)
assert.ok(bytesEqual(read, data))
} finally {
await stop()
}
})
test("large file round-trips across multiple read/write chunks", async () => {
const { client, stop } = setup()
try {
const data = pseudoRandomBytes(200 * 1024, 42)
await client.writeFile("/big.bin", streamOf(data))
const stream = await client.readFile("/big.bin")
const read = await readAll(stream)
assert.ok(bytesEqual(read, data))
} finally {
await stop()
}
})
test("mkdir then stat reports isDirectory, readdir of parent lists it", async () => {
const { client, stop } = setup()
try {
await client.mkdir("/subdir")
const st = await client.stat("/subdir")
assert.equal(st.isDirectory, true)
const entries = await client.readdir("/")
assert.ok(entries.some((e) => e.name === "subdir" && e.isDirectory))
} finally {
await stop()
}
})
test("readdir returns files with correct sizes, excludes . and ..", async () => {
const { client, stop } = setup()
try {
await client.mkdir("/docs")
const dataA = new TextEncoder().encode("aaa")
const dataB = new TextEncoder().encode("bbbbb")
await client.writeFile("/docs/a.txt", streamOf(dataA))
await client.writeFile("/docs/b.txt", streamOf(dataB))
const entries = await client.readdir("/docs")
assert.ok(!entries.some((e) => e.name === "." || e.name === ".."))
const a = entries.find((e) => e.name === "a.txt")
const b = entries.find((e) => e.name === "b.txt")
assert.ok(a && b)
assert.equal(a!.size, BigInt(dataA.length))
assert.equal(b!.size, BigInt(dataB.length))
} finally {
await stop()
}
})
test("stat on missing path rejects with VFSError not-found", async () => {
const { client, stop } = setup()
try {
await assert.rejects(
() => client.stat("/does-not-exist.txt"),
(e: unknown) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await stop()
}
})
test("mkdir of an existing dir rejects with already-exists", async () => {
const { client, stop } = setup()
try {
await client.mkdir("/dup")
await assert.rejects(
() => client.mkdir("/dup"),
(e: unknown) => e instanceof VFSError && e.code === "already-exists",
)
} finally {
await stop()
}
})
test("move renames a file", async () => {
const { client, stop } = setup()
try {
const data = new TextEncoder().encode("movable content")
await client.writeFile("/src.txt", streamOf(data))
await client.move("/src.txt", "/dest.txt")
await assert.rejects(
() => client.stat("/src.txt"),
(e: unknown) => e instanceof VFSError && e.code === "not-found",
)
const read = await readAll(await client.readFile("/dest.txt"))
assert.ok(bytesEqual(read, data))
} finally {
await stop()
}
})
test("delete removes a file", async () => {
const { client, stop } = setup()
try {
await client.writeFile("/gone.txt", streamOf(new Uint8Array([1, 2, 3])))
await client.delete("/gone.txt")
await assert.rejects(
() => client.stat("/gone.txt"),
(e: unknown) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await stop()
}
})
test("delete(path, true) recursively removes a non-empty directory tree", async () => {
const { client, stop } = setup()
try {
await client.mkdir("/tree")
await client.mkdir("/tree/nested")
await client.writeFile("/tree/file.txt", streamOf(new Uint8Array([9, 9])))
await client.writeFile("/tree/nested/deep.txt", streamOf(new Uint8Array([1])))
await client.delete("/tree", true)
await assert.rejects(
() => client.stat("/tree"),
(e: unknown) => e instanceof VFSError && e.code === "not-found",
)
} finally {
await stop()
}
})
test("readFileRange returns the correct inclusive byte slice", async () => {
const { client, stop } = setup()
try {
const data = pseudoRandomBytes(1000, 7)
await client.writeFile("/range.bin", streamOf(data))
const stream = await client.readFileRange("/range.bin", 10n, 19n)
const read = await readAll(stream)
assert.ok(bytesEqual(read, data.subarray(10, 20)))
} finally {
await stop()
}
})
test("client rejects a response with a tampered signature", async () => {
const { client, server, stop } = setup()
try {