Skip to content

Commit 4aae82a

Browse files
committed
dofs: Allow writes beside nested mounts
Use descendant-only checks while following symlink targets so an ancestor directory may contain a read-only mount without making writable siblings read-only.
1 parent b63769e commit 4aae82a

3 files changed

Lines changed: 32 additions & 4 deletions

File tree

packages/dofs/src/fs/mount-guard.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,18 @@ describe("writeFile under a read-only mount", () => {
340340
});
341341
});
342342

343+
it("allows writes through a symlink to a directory that contains a read-only mount", async () => {
344+
await withDB((db) => {
345+
mkdir(db, "/workspace/scratch", { recursive: true }, () => 0);
346+
symlink(db, "/workspace", "/link", () => 0);
347+
stageMount(db, "/workspace/r2", "read-only");
348+
349+
writeFileSync(db, "/link/scratch/file.txt", new Uint8Array([1]), {}, () => 0);
350+
351+
expect(resolveInode(db, "/workspace/scratch/file.txt")?.type).toBe("file");
352+
});
353+
});
354+
343355
it("allows writes under a read-write mount", async () => {
344356
await withDB(async (db) => {
345357
mkdir(db, "/workspace/rw", { recursive: true }, () => 0);

packages/dofs/src/fs/mount-guard.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ export function getReadOnlyMountRoots(db: Database): readonly string[] {
5151
// Both shapes must be blocked so a read-only mount survives both
5252
// vectors. Mirrors the predicate that lived in
5353
// GuardedWorkspaceFilesystem before the data-layer move.
54+
function isAtOrBelowRoot(path: string, root: string): boolean {
55+
return path === root || path.startsWith(`${root}/`);
56+
}
57+
5458
function overlapsRoot(path: string, root: string): boolean {
55-
return path === root || path.startsWith(`${root}/`) || root.startsWith(`${path}/`);
59+
return isAtOrBelowRoot(path, root) || root.startsWith(`${path}/`);
5660
}
5761

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

76+
// Point writes only need to reject paths at or below a read-only root.
77+
// Unlike recursive removal, walking through an ancestor of a mount does
78+
// not modify the protected subtree.
79+
export function assertNotInReadOnlyMount(db: Database, path: string): void {
80+
const roots = getReadOnlyMountRoots(db);
81+
for (const root of roots) {
82+
if (isAtOrBelowRoot(path, root)) {
83+
throw createWorkspaceError("EROFS", `read-only mount at ${root}: cannot modify`, path);
84+
}
85+
}
86+
}
87+
7288
// Variant for callers that already know the path is canonicalised
7389
// and want to reject a single descendant during a recursive walk
7490
// (rm's walkPostOrder). Returns the matching root or undefined; the

packages/dofs/src/fs/writeFile.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { stageBlob } from "../sync/blobs.js";
88
import { buildManifest } from "../sync/manifests.js";
99
import { pathOf } from "../sync/paths.js";
1010
import { getBlobBytes } from "./blobCache.js";
11-
import { assertNotReadOnly } from "./mount-guard.js";
11+
import { assertNotInReadOnlyMount, assertNotReadOnly } from "./mount-guard.js";
1212
import { invalidateResolveExact } from "./resolveCache.js";
1313
import {
1414
allocatePendingInode,
@@ -92,7 +92,7 @@ function resolveParent(
9292
countSymlinkFollow(follows, canonical);
9393
const target = node.link_target ?? "";
9494
const targetParts = symlinkTargetParts(target, realParts);
95-
assertNotReadOnly(db, clampedPathFromParts(targetParts));
95+
assertNotInReadOnlyMount(db, clampedPathFromParts(targetParts));
9696
if (target.startsWith("/")) {
9797
inodeStack.splice(1);
9898
realParts.splice(0);
@@ -275,7 +275,7 @@ function resolveWriteTarget(
275275
const realLinkParts = canonicalizePath(direct.canonicalPath).parts;
276276
targetParts = symlinkTargetParts(node.link_target ?? "", realLinkParts.slice(0, -1));
277277
targetCanonical = clampedPathFromParts(targetParts);
278-
assertNotReadOnly(db, targetCanonical);
278+
assertNotInReadOnlyMount(db, targetCanonical);
279279
const finalPart = targetParts.at(-1);
280280
if (finalPart === undefined || finalPart === "" || finalPart === "." || finalPart === "..") {
281281
resolveParent(db, [...targetParts, "__write_target__"], targetCanonical, follows);

0 commit comments

Comments
 (0)