Describe the bug
A symlink used as a non-final path component makes every write fail with ENOTDIR. POSIX resolves an intermediate symlink to the directory it points at; resolveParent rejects it instead.
packages/dofs/src/fs/writeFile.ts:43-72 walks the dirent chain itself rather than going through resolveInode, and types the node it reads as "file" | "dir":
const next = db.one<{ inode: number; type: "file" | "dir" }>(
"SELECT inode, type FROM vfs_nodes WHERE inode = ?",
child.child_inode,
);
...
if (next.type !== "dir") {
throw createWorkspaceError(
"ENOTDIR",
`parent path segment is not a directory: ${canonical}`,
canonical,
);
}
A symlink node has type === "symlink", so it takes the throw. The union type not including "symlink" looks like the case simply wasn't considered — the same shape shows up in the write paths described in #55.
Observed:
writeFile("/linkdir/f.txt") -> WorkspaceFsError { code: 'ENOTDIR', path: '/linkdir/f.txt' }
"parent path segment is not a directory: /linkdir/f.txt"
This is distinct from #54 (relative target on a final component) and #55 (write onto a final symlink's inode). Both of those concern the leaf; this one fails before the leaf is ever considered, and it fails for absolute targets too, so neither fix addresses it. Filing separately so the diffs stay reviewable — #55 already notes it touches three write paths, and this is a fourth site with a different failure.
Practical impact: any workspace layout using a directory symlink — /workspace/current -> /workspace/releases/v3, a node_modules link, a convenience alias — is unwritable through the link. Reads are unaffected (readFile goes through resolveInode, which follows), so the filesystem appears to work until the first write.
Expected behavior
An intermediate symlink resolves to its target directory, so writeFile("/linkdir/f.txt") where /linkdir -> /real creates /real/f.txt — matching path_resolution(7) and Node's fs.writeFile.
Steps to reproduce
Add to packages/dofs/src/fs/writeFile.test.ts (imports mkdir, symlink, resolveInode, withDB, writeFile as the neighbouring tests do):
it("writes through a symlink-to-directory in the middle of a path", async () => {
await withDB(async (db) => {
mkdir(db, "/real", {}, () => 0);
symlink(db, "/real", "/linkdir", () => 0);
await writeFile(db, "/linkdir/f.txt", "hello", {}, () => 0);
expect(resolveInode(db, "/real/f.txt")?.type).toBe("file");
});
});
npm test --workspace @cloudflare/dofs -- src/fs/writeFile.test.ts
Fails at 76d9e75 with ENOTDIR from writeFile.ts:60-65.
Equivalent on Linux, for comparison:
mkdir /tmp/real && ln -s /tmp/real /tmp/linkdir && echo hello > /tmp/linkdir/f.txt && cat /tmp/real/f.txt
# hello
Proposed fix
Resolve the parent through the normal resolver instead of the hand-rolled walk — resolveInode already follows symlinks and already has the loop guard (MAX_SYMLINK_FOLLOWS, resolve.ts:45), so the ENOTDIR check then applies to the resolved node and an intermediate link works by construction.
Two details worth deciding deliberately:
- ENOTDIR must still fire when the resolved target is a regular file (
/file.txt/x), and ELOOP when the intermediate link is cyclic. Both fall out of resolveInode but deserve explicit tests.
resolveParent is called on the streaming path both before and inside the commit transaction (writeFile.ts:147, :219), so swapping the implementation shouldn't widen that existing TOCTOU window.
I have not written this patch — unlike the leaf cases it changes which code owns path resolution for writes, which seems like a maintainer's call on scope. Happy to if it's wanted.
Environment
cloudflare/computer at 76d9e75 (current main)
- Reproduced via
packages/dofs vitest suite on Windows; not platform-specific (pure VFS logic, no FUSE involved)
Describe the bug
A symlink used as a non-final path component makes every write fail with
ENOTDIR. POSIX resolves an intermediate symlink to the directory it points at;resolveParentrejects it instead.packages/dofs/src/fs/writeFile.ts:43-72walks the dirent chain itself rather than going throughresolveInode, and types the node it reads as"file" | "dir":A symlink node has
type === "symlink", so it takes the throw. The union type not including"symlink"looks like the case simply wasn't considered — the same shape shows up in the write paths described in #55.Observed:
This is distinct from #54 (relative target on a final component) and #55 (write onto a final symlink's inode). Both of those concern the leaf; this one fails before the leaf is ever considered, and it fails for absolute targets too, so neither fix addresses it. Filing separately so the diffs stay reviewable — #55 already notes it touches three write paths, and this is a fourth site with a different failure.
Practical impact: any workspace layout using a directory symlink —
/workspace/current -> /workspace/releases/v3, anode_moduleslink, a convenience alias — is unwritable through the link. Reads are unaffected (readFilegoes throughresolveInode, which follows), so the filesystem appears to work until the first write.Expected behavior
An intermediate symlink resolves to its target directory, so
writeFile("/linkdir/f.txt")where/linkdir -> /realcreates/real/f.txt— matchingpath_resolution(7)and Node'sfs.writeFile.Steps to reproduce
Add to
packages/dofs/src/fs/writeFile.test.ts(importsmkdir,symlink,resolveInode,withDB,writeFileas the neighbouring tests do):npm test --workspace @cloudflare/dofs -- src/fs/writeFile.test.tsFails at
76d9e75withENOTDIRfromwriteFile.ts:60-65.Equivalent on Linux, for comparison:
Proposed fix
Resolve the parent through the normal resolver instead of the hand-rolled walk —
resolveInodealready follows symlinks and already has the loop guard (MAX_SYMLINK_FOLLOWS,resolve.ts:45), so the ENOTDIR check then applies to the resolved node and an intermediate link works by construction.Two details worth deciding deliberately:
/file.txt/x), and ELOOP when the intermediate link is cyclic. Both fall out ofresolveInodebut deserve explicit tests.resolveParentis called on the streaming path both before and inside the commit transaction (writeFile.ts:147,:219), so swapping the implementation shouldn't widen that existing TOCTOU window.I have not written this patch — unlike the leaf cases it changes which code owns path resolution for writes, which seems like a maintainer's call on scope. Happy to if it's wanted.
Environment
cloudflare/computerat76d9e75(currentmain)packages/dofsvitest suite on Windows; not platform-specific (pure VFS logic, no FUSE involved)