Compare commits
3
Commits
db25c6e558
...
bd78bf528d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd78bf528d | ||
|
|
e3af63ac77 | ||
|
|
dcbb042fe3 |
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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