fix(vfs): address combined metadata review
CI / lint (pull_request) Successful in 2m34s
CI / format (pull_request) Successful in 2m37s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 5m5s
CI / lint (pull_request) Successful in 2m34s
CI / format (pull_request) Successful in 2m37s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 5m5s
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
+1
-1
@@ -137,4 +137,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **`@webnet/taildrive` — share-bearing peer discovery**: `gpt-5.6-sol` added a Webnet-only `listDrivePeersWithShares(ipn)` helper that probes the existing candidates through the shared WebDAV client, positively retains peers exporting at least one share, bounds concurrent and malformed responses, and exposes a reusable per-peer `DAVClient` factory for a later whole-tailnet `AsyncVFS` hierarchy. Focused tests cover populated, empty, invalid, unreachable, and ordered peer results.
|
||||
- **`@webnet/taildrive` — peer-probe review hardening**: `gpt-5.6-sol` addressed autonomous review findings by covering the IPN dial phase with the probe deadline, closing a connection that arrives after timeout, and requiring a valid root-directory response before accepting a nonempty WebDAV listing. Regression coverage includes a permanently stalled dial and a malformed 207 response that omits the requested root.
|
||||
- **`@webnet/taildrive` — discovery API review follow-up**: `gpt-5.6-sol` renamed the narrower discovery helper to `listDrivePeersWithShares` to distinguish it from `IpnClient.listDrivePeers`, and added the conventional package-root export alongside the existing client and server subpaths.
|
||||
- **`@webnet/vfs` — combined stat and directory listing**: `gpt-5.6-sol` added the optional `AsyncVFS.statAndReaddir()` operation, with optimized Memory, File System Access, WebDAV, and SMB2 implementations. Drive PROPFIND/COPY/MOVE and delete handling, FTP server listings, SMB2 recursive operations, and Taildrive peer discovery reuse combined metadata where available while retaining inline fallbacks for other VFS implementations. Conformance, browser, protocol, malformed-response, request-count, and integration coverage preserve existing `readdir()` semantics and verify files return their own stat with no entries.
|
||||
- **`@webnet/vfs` — combined stat and directory listing**: `gpt-5.6-sol` added the optional `AsyncVFS.statAndReaddir()` operation, with optimized Memory, File System Access, WebDAV, and SMB2 implementations. Drive PROPFIND/COPY/MOVE and delete handling, FTP server listings, SMB2 recursive operations, and Taildrive peer discovery reuse combined metadata where available while retaining inline fallbacks for other VFS implementations. Conformance, browser, protocol, malformed-response, request-count, and integration coverage preserve existing `readdir()` semantics and verify files return their own stat with no entries. A Claude Opus 5 review found that SMB recursive deletion could accidentally request read-data access on files and identified implicit listing/native-copy contracts; `gpt-5.6-sol` fixed the access regression, aligned DAV self-resource validation, documented the contracts, and added request-level and conformance coverage.
|
||||
|
||||
@@ -130,9 +130,13 @@ export class DAVClient implements AsyncVFS {
|
||||
if (res.status !== 207) throw statusToVFSError(res.status, path)
|
||||
const xml = await res.text()
|
||||
const entries = parseMultistatus(xml)
|
||||
const entry = entries[0]
|
||||
if (!entry) throw new VFSError("not-found", path)
|
||||
return propsToStat(entry.href, entry.props, entry.isDirectory, this.#prefix)
|
||||
const requestedPath = normalizePath(path)
|
||||
const self = entries
|
||||
.map((entry) => propsToStat(entry.href, entry.props, entry.isDirectory, this.#prefix))
|
||||
.find((entry) => normalizePath(entry.path) === requestedPath)
|
||||
if (!self)
|
||||
throw new VFSError("not-found", `PROPFIND response omitted requested resource: ${path}`)
|
||||
return self
|
||||
}
|
||||
|
||||
async #depthOne(path: string): Promise<{ self: Stat | undefined; entries: Stat[] }> {
|
||||
|
||||
@@ -655,6 +655,36 @@ suite("DAV server HTTP", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("native COPY reports a missing source without a preliminary source stat", async () => {
|
||||
const base = new MemoryVFS()
|
||||
const statPaths: string[] = []
|
||||
const copyCalls: [string, string][] = []
|
||||
const vfs: AsyncVFS = {
|
||||
...minimalVfs(base),
|
||||
stat: (path) => {
|
||||
statPaths.push(path)
|
||||
return base.stat(path)
|
||||
},
|
||||
copy: async (src, dest) => {
|
||||
copyCalls.push([src, dest])
|
||||
throw new VFSError("not-found", src)
|
||||
},
|
||||
}
|
||||
const { fetch, close } = makeHttpPair(vfs)
|
||||
try {
|
||||
const res = await fetch("http://localhost/missing.txt", {
|
||||
method: "COPY",
|
||||
headers: { Destination: "http://localhost/dst.txt" },
|
||||
})
|
||||
assert.equal(res.status, 404)
|
||||
if (res.hasBody) await res.bytes()
|
||||
assert.deepEqual(statPaths, ["/dst.txt"])
|
||||
assert.deepEqual(copyCalls, [["/missing.txt", "/dst.txt"]])
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("COPY uses fallback copyRecursive when VFS has no copy method", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.mkdir("/src")
|
||||
@@ -723,6 +753,36 @@ suite("DAV server HTTP", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("native MOVE reports a missing source without a preliminary source stat", async () => {
|
||||
const base = new MemoryVFS()
|
||||
const statPaths: string[] = []
|
||||
const moveCalls: [string, string][] = []
|
||||
const vfs: AsyncVFS = {
|
||||
...minimalVfs(base),
|
||||
stat: (path) => {
|
||||
statPaths.push(path)
|
||||
return base.stat(path)
|
||||
},
|
||||
move: async (src, dest) => {
|
||||
moveCalls.push([src, dest])
|
||||
throw new VFSError("not-found", src)
|
||||
},
|
||||
}
|
||||
const { fetch, close } = makeHttpPair(vfs)
|
||||
try {
|
||||
const res = await fetch("http://localhost/missing.txt", {
|
||||
method: "MOVE",
|
||||
headers: { Destination: "http://localhost/dst.txt" },
|
||||
})
|
||||
assert.equal(res.status, 404)
|
||||
if (res.hasBody) await res.bytes()
|
||||
assert.deepEqual(statPaths, ["/dst.txt"])
|
||||
assert.deepEqual(moveCalls, [["/missing.txt", "/dst.txt"]])
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("MOVE uses fallback when VFS has no move method", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.writeFile("/src.txt", streamOf("content"))
|
||||
@@ -1943,9 +2003,11 @@ suite("DAVClient + server over loopback", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("statAndReaddir requires self while readdir remains permissive", async () => {
|
||||
test("stat and statAndReaddir require self while readdir remains permissive", async () => {
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const server = new Server(async ({ res }) => {
|
||||
const methods: string[] = []
|
||||
const server = new Server(async ({ req, res }) => {
|
||||
methods.push(req.method)
|
||||
res.setStatus(207)
|
||||
res.body = `<?xml version="1.0"?><D:multistatus xmlns:D="DAV:"><D:response><D:href>/made-up</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`
|
||||
})
|
||||
@@ -1953,6 +2015,10 @@ suite("DAVClient + server over loopback", () => {
|
||||
const { pool } = makeFetch(dialer, { keepAlive: false })
|
||||
const client = new DAVClient({ dialer: pool, base: "http://localhost" })
|
||||
try {
|
||||
await assert.rejects(client.stat("/"), {
|
||||
code: "not-found",
|
||||
message: "PROPFIND response omitted requested resource: /",
|
||||
})
|
||||
await assert.rejects(client.statAndReaddir("/"), {
|
||||
code: "not-found",
|
||||
message: "PROPFIND response omitted requested resource: /",
|
||||
@@ -1961,6 +2027,11 @@ suite("DAVClient + server over loopback", () => {
|
||||
(await client.readdir("/")).map((entry) => entry.path),
|
||||
["/made-up"],
|
||||
)
|
||||
await assert.rejects(client.delete("/"), {
|
||||
code: "not-found",
|
||||
message: "PROPFIND response omitted requested resource: /",
|
||||
})
|
||||
assert.deepEqual(methods, ["PROPFIND", "PROPFIND", "PROPFIND", "PROPFIND"])
|
||||
} finally {
|
||||
await pool.shutdown()
|
||||
listener.close()
|
||||
|
||||
@@ -475,11 +475,14 @@ 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 { self: st, entries } = recursive
|
||||
? await this.statAndReaddir(path)
|
||||
: { self: await this.stat(path), entries: [] }
|
||||
const st = await this.stat(path)
|
||||
await this.#delete(path, recursive ?? false, st)
|
||||
}
|
||||
|
||||
async #delete(path: string, recursive: boolean, st: Stat): Promise<void> {
|
||||
if (st.isDirectory && recursive) {
|
||||
for (const e of entries) await this.delete(e.path, true)
|
||||
const { entries } = await this.statAndReaddir(path)
|
||||
for (const entry of entries) await this.#delete(entry.path, true, entry)
|
||||
}
|
||||
const name = smbPath(path)
|
||||
return this.#guard(path, async (tree) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Command,
|
||||
Status,
|
||||
Dialect,
|
||||
Access,
|
||||
FileAttribute,
|
||||
CreateDisposition,
|
||||
CreateOptions,
|
||||
@@ -176,6 +177,7 @@ class MockSmb2Server {
|
||||
#sendUnsignedNext = false
|
||||
readonly messageIds: bigint[] = []
|
||||
readonly commands: number[] = []
|
||||
readonly creates: { path: string; desiredAccess: number }[] = []
|
||||
negotiates = 0
|
||||
badSignatures = 0
|
||||
|
||||
@@ -518,7 +520,7 @@ class MockSmb2Server {
|
||||
r.u32() // Impersonation
|
||||
r.u64() // SmbCreateFlags
|
||||
r.u64() // Reserved
|
||||
r.u32() // DesiredAccess
|
||||
const desiredAccess = r.u32()
|
||||
r.u32() // FileAttributes
|
||||
r.u32() // ShareAccess
|
||||
const createDisposition = r.u32()
|
||||
@@ -530,6 +532,7 @@ class MockSmb2Server {
|
||||
const nameOff = nameOffset - HEADER_SIZE
|
||||
const nameBytes = body.slice(nameOff, nameOff + nameLength)
|
||||
const path = fromUtf16le(nameBytes)
|
||||
this.creates.push({ path, desiredAccess })
|
||||
|
||||
const { parent } = splitPath(path)
|
||||
const existing = this.#fs.nodes.get(path)
|
||||
@@ -1094,6 +1097,20 @@ suite("smb2 client e2e", () => {
|
||||
const commands = server.commands.slice(before)
|
||||
assert.equal(commands.filter((command) => command === Command.CREATE).length, 6)
|
||||
assert.equal(commands.filter((command) => command === Command.QUERY_DIRECTORY).length, 4)
|
||||
assert.deepEqual(server.creates.slice(-6), [
|
||||
{ path: "tree", desiredAccess: Access.FILE_READ_ATTRIBUTES },
|
||||
{
|
||||
path: "tree",
|
||||
desiredAccess: Access.FILE_LIST_DIRECTORY | Access.FILE_READ_ATTRIBUTES,
|
||||
},
|
||||
{
|
||||
path: "tree\\sub",
|
||||
desiredAccess: Access.FILE_LIST_DIRECTORY | Access.FILE_READ_ATTRIBUTES,
|
||||
},
|
||||
{ path: "tree\\sub\\file.txt", desiredAccess: Access.DELETE },
|
||||
{ path: "tree\\sub", desiredAccess: Access.DELETE },
|
||||
{ path: "tree", desiredAccess: Access.DELETE },
|
||||
])
|
||||
await assert.rejects(
|
||||
client.stat("/tree"),
|
||||
(error: unknown) => error instanceof VFSError && error.code === "not-found",
|
||||
@@ -1103,6 +1120,23 @@ suite("smb2 client e2e", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("recursive delete of a file does not request read-data access", async () => {
|
||||
const { client, server, stop } = setup()
|
||||
try {
|
||||
await client.writeFile("/file.txt", streamOf(new TextEncoder().encode("data")))
|
||||
const before = server.creates.length
|
||||
|
||||
await client.delete("/file.txt", true)
|
||||
|
||||
assert.deepEqual(server.creates.slice(before), [
|
||||
{ path: "file.txt", desiredAccess: Access.FILE_READ_ATTRIBUTES },
|
||||
{ path: "file.txt", desiredAccess: Access.DELETE },
|
||||
])
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("client rejects a response with a tampered signature", async () => {
|
||||
const { client, server, stop } = setup()
|
||||
try {
|
||||
|
||||
@@ -227,6 +227,13 @@ export function testAsyncVFSConformance(options: ConformanceOptions): void {
|
||||
assert.equal(entry?.size, 7n)
|
||||
})
|
||||
|
||||
vfsTest("listed metadata agrees with stat", async ({ vfs, path }) => {
|
||||
await vfs.writeFile(path("listed.txt"), streamOf("listed"))
|
||||
const entry = (await vfs.readdir(path())).find((e) => e.name === "listed.txt")
|
||||
assert.ok(entry)
|
||||
assert.deepEqual(entry, await vfs.stat(entry.path))
|
||||
})
|
||||
|
||||
optionalTest(
|
||||
"statAndReaddir",
|
||||
"returns directory metadata and direct children",
|
||||
@@ -243,6 +250,9 @@ export function testAsyncVFSConformance(options: ConformanceOptions): void {
|
||||
"nested",
|
||||
])
|
||||
assert.ok(!result.entries.some((entry) => entry.name === "deep.txt"))
|
||||
for (const entry of result.entries) {
|
||||
assert.deepEqual(entry, await vfs.stat(entry.path))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -37,15 +37,21 @@ export interface StatAndReaddirResult {
|
||||
|
||||
export interface AsyncVFS {
|
||||
stat(path: string): Promise<Stat>
|
||||
/**
|
||||
* Returns complete metadata for each direct child of a directory. Each entry has the same
|
||||
* metadata guarantees as `stat(entry.path)` and may be reused instead of restatting that path,
|
||||
* subject to changes made concurrently with the listing.
|
||||
*/
|
||||
readdir(path: string): Promise<Stat[]>
|
||||
/**
|
||||
* Returns a path's metadata and, when it is a directory, its direct children.
|
||||
* Files are valid inputs and return an empty `entries` array.
|
||||
*
|
||||
* Implementations should expose this optional operation when they can reuse work compared with
|
||||
* calling `stat` and `readdir` separately. The result is not guaranteed to be an atomic snapshot.
|
||||
* Callers must fall back to `stat` followed by `readdir` for directories; a shared fallback is
|
||||
* intentionally deferred to issue #82.
|
||||
* `entries` follows the complete-metadata contract of `readdir`. Implementations should expose
|
||||
* this optional operation when they can reuse work compared with calling `stat` and `readdir`
|
||||
* separately. The result is not guaranteed to be an atomic snapshot. Callers must fall back to
|
||||
* `stat` followed by `readdir` for directories; a shared fallback is intentionally deferred to
|
||||
* issue #82.
|
||||
*/
|
||||
statAndReaddir?(path: string): Promise<StatAndReaddirResult>
|
||||
readFile(path: string): Promise<ReadableStream<Uint8Array>>
|
||||
@@ -53,7 +59,9 @@ export interface AsyncVFS {
|
||||
writeFile(path: string, stream: ReadableStream<Uint8Array>, size?: bigint): Promise<void>
|
||||
delete(path: string, recursive?: boolean): Promise<void>
|
||||
mkdir(path: string): Promise<void>
|
||||
/** Copies a source path, rejecting with `VFSError("not-found")` when the source is missing. */
|
||||
copy?(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void>
|
||||
/** Moves a source path, rejecting with `VFSError("not-found")` when the source is missing. */
|
||||
move?(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void>
|
||||
setProps?(path: string, props: Record<string, string>): Promise<void>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user