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
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>
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user