Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d93e09b
fix(dofs): resolve relative symlink targets
aron-cf Aug 10, 2026
0c2b567
fix(dofs): follow intermediate symlinks for writes
aron-cf Aug 10, 2026
74d3f5d
fix(dofs): follow final symlinks for writes
aron-cf Aug 10, 2026
8a965cb
chore: add changeset for dofs symlink fixes
Aug 10, 2026
96238a0
dofs: Resolve symlink targets sequentially
aron-cf Aug 10, 2026
5ef55d4
dofs: Preserve write parent errors
aron-cf Aug 10, 2026
9b1ad4b
dofs: Follow dangling write symlinks
aron-cf Aug 10, 2026
7b2f339
dofs: Invalidate resolved write paths
aron-cf Aug 10, 2026
a62d6df
dofs: Guard symlinked write paths
aron-cf Aug 10, 2026
4c03ace
dofs: Clamp upward write symlink targets
aron-cf Aug 10, 2026
796c14f
dofs: Allow writes beside nested mounts
aron-cf Aug 10, 2026
93a6e06
dofs: Guard read-only root mounts
aron-cf Aug 10, 2026
313c5eb
dofs: Allow read-only buffered opens
aron-cf Aug 10, 2026
bbf9c48
dofs: Index pending creates by real path
aron-cf Aug 10, 2026
5faa3cf
dofs: Cache pending write target paths
aron-cf Aug 10, 2026
3258289
dofs: Flush pending directory descendants
aron-cf Aug 10, 2026
4e3ba6c
dofs: Evict rejected buffered writes
aron-cf Aug 10, 2026
7cbf1da
computerd: Return EROFS from FUSE mutations
aron-cf Aug 10, 2026
5745c94
dofs: Flush lexical pending descendants
aron-cf Aug 10, 2026
d2aec0d
dofs: Flush pending files before directory moves
aron-cf Aug 10, 2026
ad8cea6
dofs: Scope pending directory flushes
aron-cf Aug 10, 2026
fb0afaa
dofs: Flush pending symlink descendants
aron-cf Aug 10, 2026
f2aede5
dofs: Resolve pending parent aliases
aron-cf Aug 10, 2026
e715023
dofs: Commit through the mutated hardlink
aron-cf Aug 10, 2026
2c4b258
dofs: Skip empty pending lookups
aron-cf Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dofs-symlink-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/dofs": patch
---

Fix symlink path resolution and write behavior. Relative symlink targets now resolve from the symlink parent, writes follow symlinked parent directories, and writes to final symlinks update or create the target file instead of storing chunks on the symlink node.
25 changes: 25 additions & 0 deletions packages/computerd/src/fuse/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,31 @@ test("FUSE ops return errno values instead of throwing for expected filesystem e
expect(await status((cb) => ops.unlink("/missing", cb))).toBe(-2);
});

test("FUSE maps read-only provider mutations to EROFS", async () => {
const { vfs } = await createNodeVirtualFileSystem();
vfs.writeFileSync("/readonly.txt", Buffer.from("content"));
const throwReadOnly = () => {
throw Object.assign(new Error("read-only mount"), { code: "EROFS" });
};
const readOnlyVfs = Object.assign(vfs, {
createFileSync: () => undefined,
writeRangeSync: throwReadOnly,
truncateFileSync: throwReadOnly,
openWriteBufferSync: () => undefined,
releaseWriteBufferSync: throwReadOnly,
});
const ops = makeFUSEOps(readOnlyVfs);

const opened = await callback((cb) => ops.open("/readonly.txt", 0, cb));
expect(opened.errno).toBe(0);
const fh = opened.result as number;
expect(await status((cb) => ops.write("/readonly.txt", fh, Buffer.from("x"), 1, 0, cb))).toBe(
-30,
);
expect(await status((cb) => ops.truncate("/readonly.txt", 0, cb))).toBe(-30);
expect(await status((cb) => ops.release("/readonly.txt", fh, cb))).toBe(-30);
});

test("FUSE unlink removes a symlink itself rather than its target", async () => {
const { vfs } = await createNodeVirtualFileSystem();
const ops = makeFUSEOps(vfs);
Expand Down
2 changes: 2 additions & 0 deletions packages/computerd/src/fuse/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const ERRNO = {
EINVAL: -22,
EPERM: -1,
EFBIG: -27,
EROFS: -30,
ENOTEMPTY: -39,
ENODATA: -61,
ENOSYS: -38,
Expand Down Expand Up @@ -1088,5 +1089,6 @@ function toErrno(error: unknown): number {
if (code === "ENOTEMPTY") return ERRNO.ENOTEMPTY;
if (code === "EINVAL") return ERRNO.EINVAL;
if (code === "EPERM") return ERRNO.EPERM;
if (code === "EROFS") return ERRNO.EROFS;
return ERRNO.EIO;
}
260 changes: 259 additions & 1 deletion packages/dofs/src/fs/mount-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,31 @@ import { describe, expect, it } from "vitest";

import type { Database } from "../storage.js";
import { stageBlob } from "../sync/blobs.js";
import { link } from "./link.js";
import { mkdir } from "./mkdir.js";
import {
assertNotReadOnly,
getReadOnlyMountRoots,
invalidateReadOnlyMountCache,
} from "./mount-guard.js";
import { readRangeSync } from "./readFile.js";
import { rename } from "./rename.js";
import { resolveInode } from "./resolve.js";
import { rm } from "./rm.js";
import { symlink } from "./symlink.js";
import { withDB } from "./with-db.js";
import { linkStagedChunksSync, writeFile, writeFileSync } from "./writeFile.js";
import {
createFileSync,
linkStagedChunksSync,
openWriteBufferForCreateSync,
openWriteBufferSync,
releaseWriteBufferSync,
truncateFileSync,
writeFile,
writeFileRangesSync,
writeFileSync,
writeRangeSync,
} from "./writeFile.js";

// Stage a read-only mount the way the workspace-side indexer
// eventually will: a row in `_vfs_mounts` plus an actual subtree
Expand Down Expand Up @@ -99,6 +112,16 @@ describe("mount-guard helpers", () => {
});
});

it("treats every path as a descendant of a read-only root mount", async () => {
await withDB((db) => {
stageMount(db, "/", "read-only");

expect(() => assertNotReadOnly(db, "/child")).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
});
});

it("read-write mounts do not register as read-only", async () => {
await withDB(async (db) => {
stageMount(db, "/workspace/rw", "read-write");
Expand All @@ -109,6 +132,21 @@ describe("mount-guard helpers", () => {
});

describe("writeFile under a read-only mount", () => {
it("rejects direct and symlinked writes under a read-only root mount", async () => {
await withDB((db) => {
mkdir(db, "/actual", {}, () => 0);
symlink(db, "/actual", "/link", () => 0);
stageMount(db, "/", "read-only");

expect(() => writeFileSync(db, "/direct.txt", new Uint8Array([1]), {}, () => 0)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
expect(() =>
writeFileSync(db, "/link/through.txt", new Uint8Array([1]), {}, () => 0),
).toThrowError(expect.objectContaining({ code: "EROFS" }));
});
});

it("rejects a streaming write under the mount root with EROFS", async () => {
await withDB(async (db) => {
// Materialise the directory before flipping the mount to
Expand Down Expand Up @@ -144,6 +182,191 @@ describe("writeFile under a read-only mount", () => {
});
});

it("allows opening and releasing a file inside a read-only mount without writing", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
writeFileSync(db, "/mnt/file.txt", new Uint8Array([1]), {}, () => 0);
stageMount(db, "/mnt", "read-only");

expect(() => openWriteBufferSync(db, "/mnt/file.txt")).not.toThrow();
expect(() => releaseWriteBufferSync(db, "/mnt/file.txt", () => 1)).not.toThrow();
expect(resolveInode(db, "/mnt/file.txt")?.type).toBe("file");
});
});

it("commits writable hardlink mutations when a read-only alias closes last", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
writeFileSync(db, "/outside.txt", new TextEncoder().encode("seed"), {}, () => 0);
link(db, "/outside.txt", "/mnt/file.txt");
stageMount(db, "/mnt", "read-only");

openWriteBufferSync(db, "/outside.txt");
openWriteBufferSync(db, "/mnt/file.txt");
writeRangeSync(db, "/outside.txt", new TextEncoder().encode("done"), 0, {}, () => 1);
releaseWriteBufferSync(db, "/outside.txt", () => 2);
expect(() => releaseWriteBufferSync(db, "/mnt/file.txt", () => 2)).not.toThrow();

expect(new TextDecoder().decode(readRangeSync(db, "/outside.txt", 0, 4))).toBe("done");
expect(new TextDecoder().decode(readRangeSync(db, "/mnt/file.txt", 0, 4))).toBe("done");
});
});

it("evicts rejected dirty bytes before a later read-only open", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
writeFileSync(db, "/mnt/file.txt", new TextEncoder().encode("original"), {}, () => 0);
openWriteBufferSync(db, "/mnt/file.txt");
writeRangeSync(db, "/mnt/file.txt", new TextEncoder().encode("dirty"), 0, {}, () => 1);
stageMount(db, "/mnt", "read-only");

expect(() => releaseWriteBufferSync(db, "/mnt/file.txt", () => 2)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
expect(new TextDecoder().decode(readRangeSync(db, "/mnt/file.txt", 0, 8))).toBe("original");
expect(() => openWriteBufferSync(db, "/mnt/file.txt")).not.toThrow();
expect(() => releaseWriteBufferSync(db, "/mnt/file.txt", () => 3)).not.toThrow();
});
});

it("rejects streaming writes through a symlinked parent before staging blobs", async () => {
await withDB(async (db) => {
mkdir(db, "/mnt", {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");
let pulls = 0;
const source = new ReadableStream<Uint8Array>(
{
pull(controller) {
pulls += 1;
controller.enqueue(new Uint8Array([1]));
controller.close();
},
},
{ highWaterMark: 0 },
);

await expect(writeFile(db, "/linkdir/new.txt", source, {}, () => 0)).rejects.toMatchObject({
code: "EROFS",
});
expect(pulls).toBe(0);
expect(db.scalar<number>("SELECT COUNT(*) FROM vfs_blobs")).toBe(0);
});
});

it("rejects writeFileSync through a symlinked parent into a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() =>
writeFileSync(db, "/linkdir/new.txt", new Uint8Array([1]), {}, () => 0),
).toThrowError(expect.objectContaining({ code: "EROFS" }));
expect(resolveInode(db, "/mnt/new.txt")).toBeNull();
});
});

it("rejects a final symlink target that escapes a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
mkdir(db, "/outside", {}, () => 0);
symlink(db, "/outside", "/mnt/escape", () => 0);
symlink(db, "/mnt/escape/file.txt", "/entry", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() => writeFileSync(db, "/entry", new Uint8Array([1]), {}, () => 0)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
expect(resolveInode(db, "/outside/file.txt")).toBeNull();
});
});

it("rejects an intermediate symlink target that escapes a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
mkdir(db, "/outside", {}, () => 0);
symlink(db, "/outside", "/mnt/escape", () => 0);
symlink(db, "/mnt/escape", "/entry", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() =>
writeFileSync(db, "/entry/file.txt", new Uint8Array([1]), {}, () => 0),
).toThrowError(expect.objectContaining({ code: "EROFS" }));
expect(resolveInode(db, "/outside/file.txt")).toBeNull();
});
});

it("rejects file creation through a symlinked parent into a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() => createFileSync(db, "/linkdir/new.txt", {}, () => 0)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
});
});

it("rejects buffered creation through a symlinked parent into a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() => openWriteBufferForCreateSync(db, "/linkdir/new.txt", {}, () => 0)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
});
});

it("rechecks the lexical path when a pending create is released", async () => {
await withDB((db) => {
mkdir(db, "/actual", {}, () => 0);
symlink(db, "/actual", "/linkdir", () => 0);
openWriteBufferForCreateSync(db, "/linkdir/new.txt", {}, () => 0);
stageMount(db, "/linkdir", "read-only");

expect(() => openWriteBufferSync(db, "/linkdir/new.txt")).not.toThrow();
expect(() => releaseWriteBufferSync(db, "/linkdir/new.txt", () => 1)).not.toThrow();
expect(() => releaseWriteBufferSync(db, "/linkdir/new.txt", () => 1)).toThrowError(
expect.objectContaining({ code: "EROFS" }),
);
expect(resolveInode(db, "/actual/new.txt")).toBeNull();
});
});

it.each([
[
"whole-file range write",
(db: Database) =>
writeFileRangesSync(
db,
"/linkdir/file.txt",
new TextEncoder().encode("new"),
[{ start: 0, end: 3 }],
{},
() => 1,
),
],
[
"positional write",
(db: Database) =>
writeRangeSync(db, "/linkdir/file.txt", new Uint8Array([1]), 0, {}, () => 1),
],
["truncate", (db: Database) => truncateFileSync(db, "/linkdir/file.txt", 0, () => 1)],
])("rejects %s through a symlinked parent into a read-only mount", async (_name, write) => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
writeFileSync(db, "/mnt/file.txt", new TextEncoder().encode("old"), {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");

expect(() => write(db)).toThrowError(expect.objectContaining({ code: "EROFS" }));
});
});

it("rejects linkStagedChunksSync under the mount root with EROFS", async () => {
await withDB(async (db) => {
mkdir(db, "/workspace/r2", { recursive: true }, () => 0);
Expand All @@ -167,6 +390,41 @@ describe("writeFile under a read-only mount", () => {
});
});

it("rejects staged writes through a symlinked parent into a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/mnt", {}, () => 0);
symlink(db, "/mnt", "/linkdir", () => 0);
stageMount(db, "/mnt", "read-only");
const bytes = new TextEncoder().encode("blocked");
const hash = new Uint8Array(createHash("sha256").update(bytes).digest());
stageBlob(db, hash, bytes, 0);

expect(() =>
linkStagedChunksSync(
db,
"/linkdir/new.txt",
["linkdir", "new.txt"],
[{ hash, size: bytes.byteLength }],
{},
0,
),
).toThrowError(expect.objectContaining({ code: "EROFS" }));
expect(resolveInode(db, "/mnt/new.txt")).toBeNull();
});
});

it("allows writes through a symlink to a directory that contains a read-only mount", async () => {
await withDB((db) => {
mkdir(db, "/workspace/scratch", { recursive: true }, () => 0);
symlink(db, "/workspace", "/link", () => 0);
stageMount(db, "/workspace/r2", "read-only");

writeFileSync(db, "/link/scratch/file.txt", new Uint8Array([1]), {}, () => 0);

expect(resolveInode(db, "/workspace/scratch/file.txt")?.type).toBe("file");
});
});

it("allows writes under a read-write mount", async () => {
await withDB(async (db) => {
mkdir(db, "/workspace/rw", { recursive: true }, () => 0);
Expand Down
18 changes: 17 additions & 1 deletion packages/dofs/src/fs/mount-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ export function getReadOnlyMountRoots(db: Database): readonly string[] {
// Both shapes must be blocked so a read-only mount survives both
// vectors. Mirrors the predicate that lived in
// GuardedWorkspaceFilesystem before the data-layer move.
function isAtOrBelowRoot(path: string, root: string): boolean {
return root === "/" || path === root || path.startsWith(`${root}/`);
}

function overlapsRoot(path: string, root: string): boolean {
return path === root || path.startsWith(`${root}/`) || root.startsWith(`${path}/`);
return isAtOrBelowRoot(path, root) || root.startsWith(`${path}/`);
}

// Throws EROFS when the path overlaps any read-only mount root.
Expand All @@ -69,6 +73,18 @@ export function assertNotReadOnly(db: Database, path: string): void {
}
}

// Point writes only need to reject paths at or below a read-only root.
// Unlike recursive removal, walking through an ancestor of a mount does
// not modify the protected subtree.
export function assertNotInReadOnlyMount(db: Database, path: string): void {
const roots = getReadOnlyMountRoots(db);
for (const root of roots) {
if (isAtOrBelowRoot(path, root)) {
throw createWorkspaceError("EROFS", `read-only mount at ${root}: cannot modify`, path);
}
}
}

// Variant for callers that already know the path is canonicalised
// and want to reject a single descendant during a recursive walk
// (rm's walkPostOrder). Returns the matching root or undefined; the
Expand Down
Loading
Loading