From bd78bf528dc7929b7aaac19e4814a7ea20b7b691 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 26 Jul 2026 19:24:58 +0000 Subject: [PATCH] test(smb2): run the AsyncVFS conformance suite 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 --- packages/smb2/src/client/client.ts | 25 +++- packages/smb2/src/smb2.test.ts | 211 ++++++++--------------------- 2 files changed, 77 insertions(+), 159 deletions(-) diff --git a/packages/smb2/src/client/client.ts b/packages/smb2/src/client/client.ts index c5c0c14..9184897 100644 --- a/packages/smb2/src/client/client.ts +++ b/packages/smb2/src/client/client.ts @@ -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 { + 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 { + async copy(src: string, dest: string, opts?: { overwrite?: boolean }): Promise { + 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) } diff --git a/packages/smb2/src/smb2.test.ts b/packages/smb2/src/smb2.test.ts index 428c85a..f314a3a 100644 --- a/packages/smb2/src/smb2.test.ts +++ b/packages/smb2/src/smb2.test.ts @@ -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 {