From 1c7f22c47cfc85e0e11f28dbb02c0c9b12c10a93 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:38:25 +0000 Subject: [PATCH 1/4] dofs: Create missing sync parent directories A child entry can arrive before its parent directory when the parent has a newer revision. Applying that stream used to fail repeatedly with a missing-parent error. Create absent parent directories before applying files and symbolic links. Replace conflicting ancestor files and symbolic links when the incoming child requires a directory, and allow safe parent creation next to read-only mounts. Cover the streaming pull path and synchronous push path with regression tests. --- .changeset/calm-ravens-sync.md | 5 + packages/dofs/src/fs/mkdir.ts | 23 ++++- packages/dofs/src/sync/apply.test.ts | 139 +++++++++++++++++++++++++++ packages/dofs/src/sync/apply.ts | 24 ++++- 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-ravens-sync.md diff --git a/.changeset/calm-ravens-sync.md b/.changeset/calm-ravens-sync.md new file mode 100644 index 00000000..b79ac8b0 --- /dev/null +++ b/.changeset/calm-ravens-sync.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +Create missing parent directories while applying sync entries. diff --git a/packages/dofs/src/fs/mkdir.ts b/packages/dofs/src/fs/mkdir.ts index 4d664c61..ddc3b72a 100644 --- a/packages/dofs/src/fs/mkdir.ts +++ b/packages/dofs/src/fs/mkdir.ts @@ -3,7 +3,7 @@ import { canonicalizePath } from "../path.js"; import { incrementRev } from "../rev.js"; import { ROOT_INODE } from "../schema/index.js"; import type { Database } from "../storage.js"; -import { assertNotReadOnly } from "./mount-guard.js"; +import { assertNotInReadOnlyMount, assertNotReadOnly } from "./mount-guard.js"; import { invalidateResolveExact } from "./resolveCache.js"; export interface MkdirOptions { @@ -68,6 +68,25 @@ function createDir( } export function mkdir(db: Database, path: string, options: MkdirOptions, now: () => number): void { + mkdirWithGuard(db, path, options, now, assertNotReadOnly); +} + +export function mkdirForSyncParents( + db: Database, + path: string, + options: MkdirOptions, + now: () => number, +): void { + mkdirWithGuard(db, path, options, now, assertNotInReadOnlyMount); +} + +function mkdirWithGuard( + db: Database, + path: string, + options: MkdirOptions, + now: () => number, + guard: (db: Database, path: string) => void, +): void { const { parts, path: canonical } = canonicalizePath(path); const recursive = options.recursive === true; const mode = (options.mode ?? 0o755) & 0o7777; @@ -79,7 +98,7 @@ export function mkdir(db: Database, path: string, options: MkdirOptions, now: () // undefined, but our docs treat mkdir("/") as nonsensical). throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); } - assertNotReadOnly(db, canonical); + guard(db, canonical); db.transactionSync(() => { const rev = incrementRev(db); diff --git a/packages/dofs/src/sync/apply.test.ts b/packages/dofs/src/sync/apply.test.ts index 8c3245c2..72073ecf 100644 --- a/packages/dofs/src/sync/apply.test.ts +++ b/packages/dofs/src/sync/apply.test.ts @@ -76,6 +76,145 @@ describe("applyChanges", () => { ); }); + describe.each(["async", "sync"] as const)("%s apply", (mode) => { + const apply = async (db: import("../storage.js").Database, entries: ChangeEntry[]) => { + if (mode === "sync") { + return applyChangesSync(db, entries, new Map(), { source: "upstream" }); + } + return applyChanges(db, entries, new Map(), { source: "upstream" }); + }; + + it("creates missing parents before applying a file", async () => { + await withDB(async (db) => { + await apply(db, [ + { + kind: "file", + rev: 4, + path: "/workspace/newdir/file.txt", + mode: 0o640, + mtime: 4, + size: 0, + chunks: [], + }, + { kind: "dir", rev: 8, path: "/workspace/newdir", mode: 0o700, mtime: 8 }, + { kind: "dir", rev: 9, path: "/workspace", mode: 0o750, mtime: 9 }, + ]); + + expect(await readFile(db, "/workspace/newdir/file.txt", "utf8")).toBe(""); + expect(resolveInode(db, "/workspace", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o750, + }); + expect(resolveInode(db, "/workspace/newdir", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o700, + }); + }); + }); + + it("creates missing parents before applying a symlink", async () => { + await withDB(async (db) => { + await apply(db, [ + { + kind: "symlink", + rev: 4, + path: "/workspace/newdir/link", + target: "../target", + mode: 0o777, + mtime: 4, + }, + { kind: "dir", rev: 8, path: "/workspace/newdir", mode: 0o700, mtime: 8 }, + { kind: "dir", rev: 9, path: "/workspace", mode: 0o750, mtime: 9 }, + ]); + + expect(readlink(db, "/workspace/newdir/link")).toBe("../target"); + expect(resolveInode(db, "/workspace", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o750, + }); + expect(resolveInode(db, "/workspace/newdir", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o700, + }); + }); + }); + + it.each(["file", "symlink"] as const)( + "replaces a blocking %s ancestor before applying a file", + async (kind) => { + await withDB(async (db) => { + if (kind === "file") { + await writeFile(db, "/workspace", "old", {}, () => 1); + } else { + mkdir(db, "/target", {}, () => 1); + await writeFile(db, "/target/keep.txt", "keep", {}, () => 2); + symlink(db, "/target", "/workspace", () => 3); + } + + await apply(db, [ + { + kind: "file", + rev: 4, + path: "/workspace/newdir/file.txt", + mode: 0o640, + mtime: 4, + size: 0, + chunks: [], + }, + { kind: "dir", rev: 8, path: "/workspace/newdir", mode: 0o700, mtime: 8 }, + { kind: "dir", rev: 9, path: "/workspace", mode: 0o750, mtime: 9 }, + ]); + + expect(await readFile(db, "/workspace/newdir/file.txt", "utf8")).toBe(""); + expect(resolveInode(db, "/workspace", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o750, + }); + expect(resolveInode(db, "/workspace/newdir", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o700, + }); + if (kind === "symlink") { + expect(await readFile(db, "/target/keep.txt", "utf8")).toBe("keep"); + } + }); + }, + ); + + it("creates a missing ancestor of a read-only mount", async () => { + await withDB(async (db) => { + db.run( + "INSERT INTO _vfs_mounts (root, kind, indexed, mode) VALUES (?, ?, 1, 'read-only')", + "/workspace/r2", + "test", + ); + invalidateReadOnlyMountCache(db); + + const result = await apply(db, [ + { + kind: "file", + rev: 4, + path: "/workspace/file.txt", + mode: 0o640, + mtime: 4, + size: 0, + chunks: [], + }, + { kind: "dir", rev: 9, path: "/workspace", mode: 0o750, mtime: 9 }, + ]); + + expect(await readFile(db, "/workspace/file.txt", "utf8")).toBe(""); + expect(resolveInode(db, "/workspace", { followSymlinks: false })?.type).toBe("dir"); + expect(result.skipped).toContainEqual({ + path: "/workspace", + mountRoot: "/workspace/r2", + op: "write", + reason: "read-only", + }); + }); + }); + }); + it("links staged chunks without reading payload bytes", async () => { const content = `${"a".repeat(CHUNK_SIZE)}b`; await withTwoDBs( diff --git a/packages/dofs/src/sync/apply.ts b/packages/dofs/src/sync/apply.ts index b9df856c..587a42a6 100644 --- a/packages/dofs/src/sync/apply.ts +++ b/packages/dofs/src/sync/apply.ts @@ -1,4 +1,4 @@ -import { mkdir } from "../fs/mkdir.js"; +import { mkdir, mkdirForSyncParents } from "../fs/mkdir.js"; import { readOnlyRootFor } from "../fs/mount-guard.js"; import { resolveInode } from "../fs/resolve.js"; import { invalidateResolveSubtree } from "../fs/resolveCache.js"; @@ -210,6 +210,25 @@ function applyDirectoryEntry(db: Database, entry: Extract mtime); + return; + } + if (ancestor.type === "dir") continue; + removeInodeTreeAtPath(db, ancestorPath, ancestor.inode, ancestor.type); + mkdirForSyncParents(db, parentPath, { recursive: true }, () => mtime); + return; + } +} + // Drive a ChangeEntry stream against `db`, batching writes so peak // memory stays bounded and a crash mid-apply leaves the DB in a // consistent state. Each batch runs inside a single transactionSync @@ -283,6 +302,7 @@ export async function applyChanges( continue; } if (entry.kind === "symlink") { + ensureParentDirectories(db, entry.path, entry.mtime); removeReplaceableFinalEntry(db, entry.path, "symlink"); symlink(db, entry.target, entry.path, () => entry.mtime); applied++; @@ -374,6 +394,7 @@ export function applyChangesSync( continue; } if (entry.kind === "symlink") { + ensureParentDirectories(db, entry.path, entry.mtime); removeReplaceableFinalEntry(db, entry.path, "symlink"); symlink(db, entry.target, entry.path, () => entry.mtime); applied++; @@ -427,6 +448,7 @@ function applyFileEntry( } assertChunkSize(staged, c.size, c.hash, entry.path); } + ensureParentDirectories(db, entry.path, entry.mtime); removeReplaceableFinalEntry(db, entry.path, "file"); const { parts, path: canonical } = canonicalizePath(entry.path); linkStagedChunksSync(db, canonical, parts, entry.chunks, { mode: entry.mode }, entry.mtime); From 060c4797f9adf391cd992bbc711092f0e440e784 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:14:08 +0000 Subject: [PATCH 2/4] dofs: Harden sync parent creation Protect read-only mount paths when replacing a blocking ancestor, and repair wrong-type ancestors for directory entries as well as files and symlinks. Resolve the common existing-parent case with one query so deeply nested sync entries do not repeatedly walk each path prefix. --- packages/dofs/src/fs/resolve.ts | 10 +++ packages/dofs/src/sync/apply.test.ts | 102 +++++++++++++++++++++++++++ packages/dofs/src/sync/apply.ts | 91 ++++++++++++++++++++---- 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/packages/dofs/src/fs/resolve.ts b/packages/dofs/src/fs/resolve.ts index 5248757c..abc100fc 100644 --- a/packages/dofs/src/fs/resolve.ts +++ b/packages/dofs/src/fs/resolve.ts @@ -106,6 +106,16 @@ export function resolveInode( return cte.node; } +// Resolve a path in one statement without following symlinks in any +// component. A path containing a symlink returns null. Sync parent +// creation uses this for the common case, then inspects each ancestor +// only when the path is missing or blocked. +export function resolveInodeWithoutSymlinks(db: Database, path: string): ResolvedInode | null { + const { parts } = canonicalizePath(path); + const resolution = resolveViaCte(db, parts); + return resolution.kind === "resolved" ? resolution.node : null; +} + interface CteRow { level: number; inode: number; diff --git a/packages/dofs/src/sync/apply.test.ts b/packages/dofs/src/sync/apply.test.ts index 72073ecf..b9a257f6 100644 --- a/packages/dofs/src/sync/apply.test.ts +++ b/packages/dofs/src/sync/apply.test.ts @@ -181,6 +181,73 @@ describe("applyChanges", () => { }, ); + it.each(["file", "symlink"] as const)( + "replaces a blocking %s ancestor when directories arrive child-first", + async (kind) => { + await withDB(async (db) => { + if (kind === "file") { + await writeFile(db, "/workspace", "old", {}, () => 1); + } else { + mkdir(db, "/target", {}, () => 1); + symlink(db, "/target", "/workspace", () => 2); + } + + await apply(db, [ + { kind: "dir", rev: 8, path: "/workspace/newdir", mode: 0o700, mtime: 8 }, + { kind: "dir", rev: 9, path: "/workspace", mode: 0o750, mtime: 9 }, + ]); + + expect(resolveInode(db, "/workspace", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o750, + }); + expect(resolveInode(db, "/workspace/newdir", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o700, + }); + }); + }, + ); + + it("keeps a blocking ancestor that protects a read-only mount", async () => { + await withDB(async (db) => { + mkdir(db, "/target/r2", { recursive: true }, () => 1); + symlink(db, "/target", "/workspace", () => 2); + db.run( + "INSERT INTO _vfs_mounts (root, kind, indexed, mode) VALUES (?, ?, 1, 'read-only')", + "/workspace/r2", + "test", + ); + invalidateReadOnlyMountCache(db); + + const result = await apply(db, [ + { + kind: "file", + rev: 4, + path: "/workspace/newdir/file.txt", + mode: 0o640, + mtime: 4, + size: 0, + chunks: [], + }, + ]); + + expect(result.applied).toBe(0); + expect(result.skipped).toEqual([ + { + path: "/workspace/newdir/file.txt", + mountRoot: "/workspace/r2", + op: "write", + reason: "read-only", + }, + ]); + expect(resolveInode(db, "/workspace", { followSymlinks: false })?.type).toBe("symlink"); + expect(readlink(db, "/workspace")).toBe("/target"); + expect(resolveInode(db, "/workspace/r2")?.type).toBe("dir"); + expect(resolveInode(db, "/workspace/newdir/file.txt")).toBeNull(); + }); + }); + it("creates a missing ancestor of a read-only mount", async () => { await withDB(async (db) => { db.run( @@ -215,6 +282,41 @@ describe("applyChanges", () => { }); }); + it("checks an existing parent chain in linear database reads", async () => { + await withDB(async (db) => { + const parentParts = Array.from({ length: 8 }, (_, index) => `d${index}`); + const parentPath = `/${parentParts.join("/")}`; + mkdir(db, parentPath, { recursive: true }, () => 1); + + const all = vi.spyOn(db, "all"); + try { + await applyChanges( + db, + [ + { + kind: "file", + rev: 10, + path: `${parentPath}/file.txt`, + mode: 0o640, + mtime: 10, + size: 0, + chunks: [], + }, + ], + new Map(), + ); + + const childLookups = all.mock.calls.filter( + ([query]) => + query === "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", + ); + expect(childLookups.length).toBeLessThanOrEqual(2 * (parentParts.length + 1)); + } finally { + all.mockRestore(); + } + }); + }); + it("links staged chunks without reading payload bytes", async () => { const content = `${"a".repeat(CHUNK_SIZE)}b`; await withTwoDBs( diff --git a/packages/dofs/src/sync/apply.ts b/packages/dofs/src/sync/apply.ts index 587a42a6..4d914f5a 100644 --- a/packages/dofs/src/sync/apply.ts +++ b/packages/dofs/src/sync/apply.ts @@ -1,6 +1,6 @@ import { mkdir, mkdirForSyncParents } from "../fs/mkdir.js"; import { readOnlyRootFor } from "../fs/mount-guard.js"; -import { resolveInode } from "../fs/resolve.js"; +import { resolveInode, resolveInodeWithoutSymlinks } from "../fs/resolve.js"; import { invalidateResolveSubtree } from "../fs/resolveCache.js"; import { rm } from "../fs/rm.js"; import { symlink } from "../fs/symlink.js"; @@ -210,23 +210,29 @@ function applyDirectoryEntry(db: Database, entry: Extract mtime); - return; + return undefined; } if (ancestor.type === "dir") continue; + const blockingRoot = readOnlyRootFor(db, ancestorPath); + if (blockingRoot !== undefined) return blockingRoot; removeInodeTreeAtPath(db, ancestorPath, ancestor.inode, ancestor.type); mkdirForSyncParents(db, parentPath, { recursive: true }, () => mtime); - return; + return undefined; } + return undefined; } // Drive a ChangeEntry stream against `db`, batching writes so peak @@ -295,6 +301,16 @@ export async function applyChanges( continue; } if (entry.kind === "dir") { + const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); + if (blockingParentRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: blockingParentRoot, + op: "write", + reason: "read-only", + }); + continue; + } applyDirectoryEntry(db, entry); applied++; pathsInBatch++; @@ -302,7 +318,16 @@ export async function applyChanges( continue; } if (entry.kind === "symlink") { - ensureParentDirectories(db, entry.path, entry.mtime); + const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); + if (blockingParentRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: blockingParentRoot, + op: "write", + reason: "read-only", + }); + continue; + } removeReplaceableFinalEntry(db, entry.path, "symlink"); symlink(db, entry.target, entry.path, () => entry.mtime); applied++; @@ -310,9 +335,18 @@ export async function applyChanges( if (pathsInBatch >= maxPaths) flush(); continue; } - const total = applyFileEntry(db, entry, objects); + const fileResult = applyFileEntry(db, entry, objects); + if (fileResult.blockingRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: fileResult.blockingRoot, + op: "write", + reason: "read-only", + }); + continue; + } applied++; - bytesInBatch += total; + bytesInBatch += fileResult.total; pathsInBatch++; if (bytesInBatch >= maxBytes || pathsInBatch >= maxPaths) flush(); } @@ -387,6 +421,16 @@ export function applyChangesSync( continue; } if (entry.kind === "dir") { + const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); + if (blockingParentRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: blockingParentRoot, + op: "write", + reason: "read-only", + }); + continue; + } applyDirectoryEntry(db, entry); applied++; pathsInBatch++; @@ -394,7 +438,16 @@ export function applyChangesSync( continue; } if (entry.kind === "symlink") { - ensureParentDirectories(db, entry.path, entry.mtime); + const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); + if (blockingParentRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: blockingParentRoot, + op: "write", + reason: "read-only", + }); + continue; + } removeReplaceableFinalEntry(db, entry.path, "symlink"); symlink(db, entry.target, entry.path, () => entry.mtime); applied++; @@ -402,9 +455,18 @@ export function applyChangesSync( if (pathsInBatch >= maxPaths) flush(); continue; } - const total = applyFileEntry(db, entry, objects); + const fileResult = applyFileEntry(db, entry, objects); + if (fileResult.blockingRoot !== undefined) { + skipped.push({ + path: entry.path, + mountRoot: fileResult.blockingRoot, + op: "write", + reason: "read-only", + }); + continue; + } applied++; - bytesInBatch += total; + bytesInBatch += fileResult.total; pathsInBatch++; if (bytesInBatch >= maxBytes || pathsInBatch >= maxPaths) flush(); } @@ -430,7 +492,7 @@ function applyFileEntry( db: Database, entry: Extract, objects: Map, -): number { +): { total: number; blockingRoot?: string } { assertChunkWindows(entry.chunks, entry.path); let total = 0; for (const c of entry.chunks) { @@ -448,11 +510,12 @@ function applyFileEntry( } assertChunkSize(staged, c.size, c.hash, entry.path); } - ensureParentDirectories(db, entry.path, entry.mtime); + const blockingRoot = ensureParentDirectories(db, entry.path, entry.mtime); + if (blockingRoot !== undefined) return { total, blockingRoot }; removeReplaceableFinalEntry(db, entry.path, "file"); const { parts, path: canonical } = canonicalizePath(entry.path); linkStagedChunksSync(db, canonical, parts, entry.chunks, { mode: entry.mode }, entry.mtime); - return total; + return { total }; } // Return a staged chunk's size without loading its payload bytes. From 2ff0bb11b8ec1722084da408d36d984459e560da Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:29:51 +0000 Subject: [PATCH 3/4] dofs: Preserve reachable sync parent symlinks Let file applies use an existing parent that resolves through a symlink. Keep structural parent repair for missing targets so child-first streams still converge. --- packages/dofs/src/sync/apply.test.ts | 25 +++++++++++++++++++++++++ packages/dofs/src/sync/apply.ts | 14 ++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/dofs/src/sync/apply.test.ts b/packages/dofs/src/sync/apply.test.ts index b9a257f6..6bc97422 100644 --- a/packages/dofs/src/sync/apply.test.ts +++ b/packages/dofs/src/sync/apply.test.ts @@ -181,6 +181,31 @@ describe("applyChanges", () => { }, ); + it("writes a file through a reachable symlink parent", async () => { + await withDB(async (db) => { + mkdir(db, "/target", {}, () => 1); + await writeFile(db, "/target/keep.txt", "keep", {}, () => 2); + symlink(db, "/target", "/workspace", () => 3); + + await apply(db, [ + { + kind: "file", + rev: 4, + path: "/workspace/new.txt", + mode: 0o640, + mtime: 4, + size: 0, + chunks: [], + }, + ]); + + expect(resolveInode(db, "/workspace", { followSymlinks: false })?.type).toBe("symlink"); + expect(readlink(db, "/workspace")).toBe("/target"); + expect(await readFile(db, "/workspace/keep.txt", "utf8")).toBe("keep"); + expect(await readFile(db, "/target/new.txt", "utf8")).toBe(""); + }); + }); + it.each(["file", "symlink"] as const)( "replaces a blocking %s ancestor when directories arrive child-first", async (kind) => { diff --git a/packages/dofs/src/sync/apply.ts b/packages/dofs/src/sync/apply.ts index 4d914f5a..6e47f9bb 100644 --- a/packages/dofs/src/sync/apply.ts +++ b/packages/dofs/src/sync/apply.ts @@ -210,13 +210,21 @@ function applyDirectoryEntry(db: Database, entry: Extract Date: Tue, 18 Aug 2026 21:05:03 +0000 Subject: [PATCH 4/4] dofs: Apply entries through parent symlinks Resolve a reachable symlinked parent to its real directory before applying files, directories, or symlinks. Keep replacing dangling symlink ancestors so child-first streams can still create their required parent directories. --- packages/dofs/src/sync/apply.test.ts | 47 ++++++++++++++++- packages/dofs/src/sync/apply.ts | 79 ++++++++++++++++------------ 2 files changed, 89 insertions(+), 37 deletions(-) diff --git a/packages/dofs/src/sync/apply.test.ts b/packages/dofs/src/sync/apply.test.ts index 6bc97422..2076d9b9 100644 --- a/packages/dofs/src/sync/apply.test.ts +++ b/packages/dofs/src/sync/apply.test.ts @@ -206,6 +206,50 @@ describe("applyChanges", () => { }); }); + it("creates a directory through a reachable symlink parent", async () => { + await withDB(async (db) => { + mkdir(db, "/target", {}, () => 1); + await writeFile(db, "/target/keep.txt", "keep", {}, () => 2); + symlink(db, "/target", "/workspace", () => 3); + + await apply(db, [ + { kind: "dir", rev: 4, path: "/workspace/newdir", mode: 0o700, mtime: 4 }, + ]); + + expect(resolveInode(db, "/workspace", { followSymlinks: false })?.type).toBe("symlink"); + expect(readlink(db, "/workspace")).toBe("/target"); + expect(await readFile(db, "/workspace/keep.txt", "utf8")).toBe("keep"); + expect(resolveInode(db, "/target/newdir", { followSymlinks: false })).toMatchObject({ + type: "dir", + mode: 0o700, + }); + }); + }); + + it("creates a symlink through a reachable symlink parent", async () => { + await withDB(async (db) => { + mkdir(db, "/target", {}, () => 1); + await writeFile(db, "/target/keep.txt", "keep", {}, () => 2); + symlink(db, "/target", "/workspace", () => 3); + + await apply(db, [ + { + kind: "symlink", + rev: 4, + path: "/workspace/newlink", + target: "keep.txt", + mode: 0o777, + mtime: 4, + }, + ]); + + expect(resolveInode(db, "/workspace", { followSymlinks: false })?.type).toBe("symlink"); + expect(readlink(db, "/workspace")).toBe("/target"); + expect(await readFile(db, "/workspace/keep.txt", "utf8")).toBe("keep"); + expect(readlink(db, "/target/newlink")).toBe("keep.txt"); + }); + }); + it.each(["file", "symlink"] as const)( "replaces a blocking %s ancestor when directories arrive child-first", async (kind) => { @@ -213,8 +257,7 @@ describe("applyChanges", () => { if (kind === "file") { await writeFile(db, "/workspace", "old", {}, () => 1); } else { - mkdir(db, "/target", {}, () => 1); - symlink(db, "/target", "/workspace", () => 2); + symlink(db, "/missing", "/workspace", () => 1); } await apply(db, [ diff --git a/packages/dofs/src/sync/apply.ts b/packages/dofs/src/sync/apply.ts index 6e47f9bb..a4dfdbaf 100644 --- a/packages/dofs/src/sync/apply.ts +++ b/packages/dofs/src/sync/apply.ts @@ -12,6 +12,7 @@ import type { Database } from "../storage.js"; import { stageBlob } from "./blobs.js"; import type { ChangeEntry } from "./changes.js"; import { computeManifestHash } from "./manifests.js"; +import { pathOf } from "./paths.js"; // One container-side change that landed under a read-only mount and // was therefore skipped rather than applied. Callers (the workspace @@ -214,16 +215,24 @@ function ensureParentDirectories( db: Database, path: string, mtime: number, - options: { allowReachableSymlinkParent?: boolean } = {}, -): string | undefined { - const { parts } = canonicalizePath(path); - if (parts.length < 2) return undefined; +): { path: string; blockingRoot?: string } { + const { parts, path: canonical } = canonicalizePath(path); + if (parts.length < 2) return { path: canonical }; const parentPath = `/${parts.slice(0, -1).join("/")}`; const parent = resolveInodeWithoutSymlinks(db, parentPath); - if (parent?.type === "dir") return undefined; - if (options.allowReachableSymlinkParent && resolveInode(db, parentPath)?.type === "dir") { - return undefined; + if (parent?.type === "dir") return { path: canonical }; + + const reachableParent = resolveInode(db, parentPath); + if (reachableParent?.type === "dir") { + const resolvedParentPath = pathOf(db, reachableParent.inode); + if (resolvedParentPath === null) { + throw new Error(`applyChanges: resolved parent is unreachable for ${canonical}`); + } + const leafName = parts[parts.length - 1]; + const resolvedPath = + resolvedParentPath === "/" ? `/${leafName}` : `${resolvedParentPath}/${leafName}`; + return { path: resolvedPath, blockingRoot: readOnlyRootFor(db, resolvedPath) }; } for (let i = 0; i < parts.length - 1; i++) { @@ -231,16 +240,16 @@ function ensureParentDirectories( const ancestor = resolveInode(db, ancestorPath, { followSymlinks: false }); if (ancestor === null) { mkdirForSyncParents(db, parentPath, { recursive: true }, () => mtime); - return undefined; + return { path: canonical }; } if (ancestor.type === "dir") continue; const blockingRoot = readOnlyRootFor(db, ancestorPath); - if (blockingRoot !== undefined) return blockingRoot; + if (blockingRoot !== undefined) return { path: canonical, blockingRoot }; removeInodeTreeAtPath(db, ancestorPath, ancestor.inode, ancestor.type); mkdirForSyncParents(db, parentPath, { recursive: true }, () => mtime); - return undefined; + return { path: canonical }; } - return undefined; + return { path: canonical }; } // Drive a ChangeEntry stream against `db`, batching writes so peak @@ -309,35 +318,35 @@ export async function applyChanges( continue; } if (entry.kind === "dir") { - const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); - if (blockingParentRoot !== undefined) { + const parentResult = ensureParentDirectories(db, entry.path, entry.mtime); + if (parentResult.blockingRoot !== undefined) { skipped.push({ path: entry.path, - mountRoot: blockingParentRoot, + mountRoot: parentResult.blockingRoot, op: "write", reason: "read-only", }); continue; } - applyDirectoryEntry(db, entry); + applyDirectoryEntry(db, { ...entry, path: parentResult.path }); applied++; pathsInBatch++; if (pathsInBatch >= maxPaths) flush(); continue; } if (entry.kind === "symlink") { - const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); - if (blockingParentRoot !== undefined) { + const parentResult = ensureParentDirectories(db, entry.path, entry.mtime); + if (parentResult.blockingRoot !== undefined) { skipped.push({ path: entry.path, - mountRoot: blockingParentRoot, + mountRoot: parentResult.blockingRoot, op: "write", reason: "read-only", }); continue; } - removeReplaceableFinalEntry(db, entry.path, "symlink"); - symlink(db, entry.target, entry.path, () => entry.mtime); + removeReplaceableFinalEntry(db, parentResult.path, "symlink"); + symlink(db, entry.target, parentResult.path, () => entry.mtime); applied++; pathsInBatch++; if (pathsInBatch >= maxPaths) flush(); @@ -429,35 +438,35 @@ export function applyChangesSync( continue; } if (entry.kind === "dir") { - const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); - if (blockingParentRoot !== undefined) { + const parentResult = ensureParentDirectories(db, entry.path, entry.mtime); + if (parentResult.blockingRoot !== undefined) { skipped.push({ path: entry.path, - mountRoot: blockingParentRoot, + mountRoot: parentResult.blockingRoot, op: "write", reason: "read-only", }); continue; } - applyDirectoryEntry(db, entry); + applyDirectoryEntry(db, { ...entry, path: parentResult.path }); applied++; pathsInBatch++; if (pathsInBatch >= maxPaths) flush(); continue; } if (entry.kind === "symlink") { - const blockingParentRoot = ensureParentDirectories(db, entry.path, entry.mtime); - if (blockingParentRoot !== undefined) { + const parentResult = ensureParentDirectories(db, entry.path, entry.mtime); + if (parentResult.blockingRoot !== undefined) { skipped.push({ path: entry.path, - mountRoot: blockingParentRoot, + mountRoot: parentResult.blockingRoot, op: "write", reason: "read-only", }); continue; } - removeReplaceableFinalEntry(db, entry.path, "symlink"); - symlink(db, entry.target, entry.path, () => entry.mtime); + removeReplaceableFinalEntry(db, parentResult.path, "symlink"); + symlink(db, entry.target, parentResult.path, () => entry.mtime); applied++; pathsInBatch++; if (pathsInBatch >= maxPaths) flush(); @@ -518,12 +527,12 @@ function applyFileEntry( } assertChunkSize(staged, c.size, c.hash, entry.path); } - const blockingRoot = ensureParentDirectories(db, entry.path, entry.mtime, { - allowReachableSymlinkParent: true, - }); - if (blockingRoot !== undefined) return { total, blockingRoot }; - removeReplaceableFinalEntry(db, entry.path, "file"); - const { parts, path: canonical } = canonicalizePath(entry.path); + const parentResult = ensureParentDirectories(db, entry.path, entry.mtime); + if (parentResult.blockingRoot !== undefined) { + return { total, blockingRoot: parentResult.blockingRoot }; + } + removeReplaceableFinalEntry(db, parentResult.path, "file"); + const { parts, path: canonical } = canonicalizePath(parentResult.path); linkStagedChunksSync(db, canonical, parts, entry.chunks, { mode: entry.mode }, entry.mtime); return { total }; }