Describe the bug
writeFile/writeFileSync in @cloudflare/dofs do not follow a trailing symlink. When the final path component is a symlink, the write lands on the symlink inode itself: chunk rows are attached to a type='symlink' node and its size is stamped with the written byte length, while link_target remains. The written bytes are unreachable through any path afterwards, because reads DO follow the link (packages/dofs/src/fs/readFile.ts:52) and return the old target content.
Locations:
packages/dofs/src/fs/writeFile.ts:941-959: writeFileSync takes existing.child_inode from the final dirent as the write target; the type guard at :952-958 only rejects "dir", so "symlink" slips through.
- Same unguarded shape in
writeFileStreaming (:227-239) and writeFileRangesSync (:1015-1031).
:962-992: chunks are deleted/inserted for the symlink inode and UPDATE vfs_nodes SET ... size = ? stamps it, violating the documented invariant "Always 0 for directories and symlinks" (resolve.ts:12-14).
The sync layer already works around this hazard internally (removeReplaceableFinalEntry, packages/dofs/src/sync/apply.ts:84-94), but the public Filesystem.writeFile (fs/filesystem.ts:99-105) has no such guard.
Consequences beyond the lost write: the orphaned chunk rows hang off a symlink-typed node forever (gc()'s orphan gate keys off vfs_chunks references, so the blobs are pinned), and read/write of the same path address different inodes, which is incoherent for any workload that writes through a symlinked config or output path. Node's fs.writeFileSync follows trailing symlinks, so this diverges from the semantics the package mirrors elsewhere.
Expected behavior
POSIX open(2)/Node semantics: a write through /link -> /target writes /target; both readFile("/link") and readFile("/target") then return the new content. Writing through a dangling symlink creates the target (like open(O_CREAT)).
Steps to reproduce
Test against packages/dofs (existing withDB harness), run with npm test --workspace @cloudflare/dofs -- <file>:
await writeFile(db, "/target", "old-content", {}, () => 0);
symlink(db, "/target", "/link", () => 0);
await writeFile(db, "/link", "NEW-CONTENT", {}, () => 0); // POSIX: writes /target
await readFile(db, "/link", "utf8"); // expected "NEW-CONTENT"
await readFile(db, "/target", "utf8"); // expected "NEW-CONTENT"
Actual output:
AssertionError: expected 'old-content' to be 'NEW-CONTENT' // both reads return "old-content"
Corruption proof (chunk rows now hang off a symlink-typed node):
SELECT COUNT(*) FROM vfs_chunks c JOIN vfs_nodes n ON n.inode = c.inode WHERE n.type = 'symlink'
-- expected 0, received 1
Proposed fix
In each final-dirent write path (writeFileSync, writeFileStreaming's commit transaction, writeFileRangesSync, plus the EEXIST probes in createFileSync/openWriteBufferForCreateSync): widen the node-type read to include "symlink" and, on hitting one, re-resolve the path with resolveInode (follow mode) and direct the write at the resolved file inode, creating the target when the link dangles. A minimal safe stopgap is to throw (O_NOFOLLOW-style rejection) rather than mutate the symlink inode. Related same-family issue observed in source (not separately reproduced): the write paths' private resolveParent (writeFile.ts:43-72) doesn't follow intermediate symlinks either, so writeFile("/link-to-dir/file") throws spurious ENOTDIR; intermediate-segment following should ride along so the write surface matches the read surface.
Environment
cloudflare/computer at 76d9e75 (current main), local checkout
npm test --workspace @cloudflare/dofs baseline: 436/436 pass before adding the repro
- Node v22.18.0, npm 11.8.0, Windows 11 (platform-independent; pure SQLite/TS logic)
Describe the bug
writeFile/writeFileSyncin@cloudflare/dofsdo not follow a trailing symlink. When the final path component is a symlink, the write lands on the symlink inode itself: chunk rows are attached to atype='symlink'node and itssizeis stamped with the written byte length, whilelink_targetremains. The written bytes are unreachable through any path afterwards, because reads DO follow the link (packages/dofs/src/fs/readFile.ts:52) and return the old target content.Locations:
packages/dofs/src/fs/writeFile.ts:941-959:writeFileSynctakesexisting.child_inodefrom the final dirent as the write target; the type guard at:952-958only rejects"dir", so"symlink"slips through.writeFileStreaming(:227-239) andwriteFileRangesSync(:1015-1031).:962-992: chunks are deleted/inserted for the symlink inode andUPDATE vfs_nodes SET ... size = ?stamps it, violating the documented invariant "Always 0 for directories and symlinks" (resolve.ts:12-14).The sync layer already works around this hazard internally (
removeReplaceableFinalEntry,packages/dofs/src/sync/apply.ts:84-94), but the publicFilesystem.writeFile(fs/filesystem.ts:99-105) has no such guard.Consequences beyond the lost write: the orphaned chunk rows hang off a symlink-typed node forever (
gc()'s orphan gate keys offvfs_chunksreferences, so the blobs are pinned), and read/write of the same path address different inodes, which is incoherent for any workload that writes through a symlinked config or output path. Node'sfs.writeFileSyncfollows trailing symlinks, so this diverges from the semantics the package mirrors elsewhere.Expected behavior
POSIX
open(2)/Node semantics: a write through/link -> /targetwrites/target; bothreadFile("/link")andreadFile("/target")then return the new content. Writing through a dangling symlink creates the target (likeopen(O_CREAT)).Steps to reproduce
Test against
packages/dofs(existingwithDBharness), run withnpm test --workspace @cloudflare/dofs -- <file>:Actual output:
Corruption proof (chunk rows now hang off a symlink-typed node):
Proposed fix
In each final-dirent write path (
writeFileSync,writeFileStreaming's commit transaction,writeFileRangesSync, plus theEEXISTprobes increateFileSync/openWriteBufferForCreateSync): widen the node-type read to include"symlink"and, on hitting one, re-resolve the path withresolveInode(follow mode) and direct the write at the resolved file inode, creating the target when the link dangles. A minimal safe stopgap is to throw (O_NOFOLLOW-style rejection) rather than mutate the symlink inode. Related same-family issue observed in source (not separately reproduced): the write paths' privateresolveParent(writeFile.ts:43-72) doesn't follow intermediate symlinks either, sowriteFile("/link-to-dir/file")throws spuriousENOTDIR; intermediate-segment following should ride along so the write surface matches the read surface.Environment
cloudflare/computerat76d9e75(currentmain), local checkoutnpm test --workspace @cloudflare/dofsbaseline: 436/436 pass before adding the repro