Skip to content

dofs: writeFile to a path whose final component is a symlink writes onto the symlink inode: data silently lost, schema invariant broken #55

Description

@rajpreetcodes

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions