Describe the bug
@cloudflare/dofs stores relative symlink targets verbatim, as documented, but any path resolution that traverses such a link throws EINVAL "Invalid path (must be absolute)" instead of resolving the target relative to the link's directory.
The doc comments promise the opposite: packages/dofs/src/fs/symlink.ts:9-11 and packages/dofs/src/fs/filesystem.ts:122-124 both say "The target is stored verbatim; it can be relative or absolute and is allowed to dangle. resolveInode follows it transparently."
The cause: resolveParts (packages/dofs/src/fs/resolve.ts:229) follows a link by feeding the stored target straight into canonicalizePath, and canonicalizePath (packages/dofs/src/path.ts:15-17) throws for anything not starting with /.
Creation and readlink succeed, so the landmine is armed at create time and detonates on first traversal. The sync layer replays container-side symlink entries verbatim (packages/dofs/src/sync/apply.ts:286, :405), so an ordinary ln -s foo bar inside the container (or any node_modules/.bin/* entry) imports cleanly and then breaks every stat/readFile/traversal through the link on the host side.
A dangling relative link is also mapped to the wrong error code: stat on it throws EINVAL where POSIX requires ENOENT, which breaks callers doing ENOENT-tolerant probing.
Expected behavior
Per POSIX path_resolution(7) and the package's own doc comments: a relative target resolves against the directory containing the link. stat("/dir/link") with link -> target resolves /dir/target. A dangling relative target surfaces as ENOENT, not EINVAL.
Steps to reproduce
Add this test to packages/dofs (uses the existing withDB harness from src/fs/with-db.ts) and run npm test --workspace @cloudflare/dofs -- <file>:
mkdir(db, "/dir", {}, () => 0);
await writeFile(db, "/dir/target", "hello", {}, () => 0);
symlink(db, "target", "/dir/link", () => 0); // stored fine, readlink returns "target"
stat(db, "/dir/link"); // POSIX: resolves /dir/target (file, size 5)
await readFile(db, "/dir/link", "utf8"); // POSIX: "hello"
Actual output:
WorkspaceFsError: Invalid path (must be absolute): target
❯ canonicalizePath src/path.ts:16:11
❯ resolveParts src/fs/resolve.ts:229:41
❯ resolveInode src/fs/resolve.ts:103:12
❯ statShared src/fs/stat.ts:57:16
readFile rejects with the same error. And for the dangling case, symlink(db, "nowhere", "/dangling") then stat(db, "/dangling"):
AssertionError: expected 'EINVAL' to be 'ENOENT'
Proposed fix
In the symlink branch of resolveParts (resolve.ts:223-243): when link_target does not start with /, resolve it against the already-consumed parent segments ([...parts.slice(0, i), ...targetParts]) before canonicalizing, clamping .. at root per POSIX instead of throwing. Any target that still fails to canonicalize should surface as a dangling link (return null -> ENOENT), never EINVAL. The CTE fast path (resolve.ts:101-106) already falls back to the loop for any symlink, so the loop is the single place to fix. Tests worth adding: target, ./target, ../sibling/target, dangling relative target.
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 (bug is platform-independent; it is pure SQLite/TS path logic)
Describe the bug
@cloudflare/dofsstores relative symlink targets verbatim, as documented, but any path resolution that traverses such a link throwsEINVAL "Invalid path (must be absolute)"instead of resolving the target relative to the link's directory.The doc comments promise the opposite:
packages/dofs/src/fs/symlink.ts:9-11andpackages/dofs/src/fs/filesystem.ts:122-124both say "The target is stored verbatim; it can be relative or absolute and is allowed to dangle. resolveInode follows it transparently."The cause:
resolveParts(packages/dofs/src/fs/resolve.ts:229) follows a link by feeding the stored target straight intocanonicalizePath, andcanonicalizePath(packages/dofs/src/path.ts:15-17) throws for anything not starting with/.Creation and
readlinksucceed, so the landmine is armed at create time and detonates on first traversal. The sync layer replays container-side symlink entries verbatim (packages/dofs/src/sync/apply.ts:286,:405), so an ordinaryln -s foo barinside the container (or anynode_modules/.bin/*entry) imports cleanly and then breaks everystat/readFile/traversal through the link on the host side.A dangling relative link is also mapped to the wrong error code:
staton it throwsEINVALwhere POSIX requiresENOENT, which breaks callers doingENOENT-tolerant probing.Expected behavior
Per POSIX
path_resolution(7)and the package's own doc comments: a relative target resolves against the directory containing the link.stat("/dir/link")withlink -> targetresolves/dir/target. A dangling relative target surfaces asENOENT, notEINVAL.Steps to reproduce
Add this test to
packages/dofs(uses the existingwithDBharness fromsrc/fs/with-db.ts) and runnpm test --workspace @cloudflare/dofs -- <file>:Actual output:
readFilerejects with the same error. And for the dangling case,symlink(db, "nowhere", "/dangling")thenstat(db, "/dangling"):Proposed fix
In the symlink branch of
resolveParts(resolve.ts:223-243): whenlink_targetdoes not start with/, resolve it against the already-consumed parent segments ([...parts.slice(0, i), ...targetParts]) before canonicalizing, clamping..at root per POSIX instead of throwing. Any target that still fails to canonicalize should surface as a dangling link (return null->ENOENT), neverEINVAL. The CTE fast path (resolve.ts:101-106) already falls back to the loop for any symlink, so the loop is the single place to fix. Tests worth adding:target,./target,../sibling/target, dangling relative target.Environment
cloudflare/computerat76d9e75(currentmain), local checkoutnpm test --workspace @cloudflare/dofsbaseline: 436/436 pass before adding the repro