Skip to content

dofs: resolving through a symlink with a relative target throws EINVAL instead of resolving relative to the link's directory #54

Description

@rajpreetcodes

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)

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