diff --git a/.changeset/dofs-symlink-fixes.md b/.changeset/dofs-symlink-fixes.md new file mode 100644 index 00000000..16d1cfd1 --- /dev/null +++ b/.changeset/dofs-symlink-fixes.md @@ -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. diff --git a/packages/computerd/src/fuse/driver.test.ts b/packages/computerd/src/fuse/driver.test.ts index 97f377b3..d65ffe13 100644 --- a/packages/computerd/src/fuse/driver.test.ts +++ b/packages/computerd/src/fuse/driver.test.ts @@ -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); diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index afefd202..7c6db985 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -14,6 +14,7 @@ const ERRNO = { EINVAL: -22, EPERM: -1, EFBIG: -27, + EROFS: -30, ENOTEMPTY: -39, ENODATA: -61, ENOSYS: -38, @@ -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; } diff --git a/packages/dofs/src/fs/mount-guard.test.ts b/packages/dofs/src/fs/mount-guard.test.ts index 8e4d71c4..4558d741 100644 --- a/packages/dofs/src/fs/mount-guard.test.ts +++ b/packages/dofs/src/fs/mount-guard.test.ts @@ -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 @@ -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"); @@ -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 @@ -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( + { + 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("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); @@ -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); diff --git a/packages/dofs/src/fs/mount-guard.ts b/packages/dofs/src/fs/mount-guard.ts index 6a2aadd3..fdd67164 100644 --- a/packages/dofs/src/fs/mount-guard.ts +++ b/packages/dofs/src/fs/mount-guard.ts @@ -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. @@ -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 diff --git a/packages/dofs/src/fs/pendingWriteBuffer.ts b/packages/dofs/src/fs/pendingWriteBuffer.ts new file mode 100644 index 00000000..ccec49bb --- /dev/null +++ b/packages/dofs/src/fs/pendingWriteBuffer.ts @@ -0,0 +1,24 @@ +import { canonicalizePath } from "../path.js"; +import type { Database } from "../storage.js"; +import { resolveInode } from "./resolve.js"; +import { + getPendingWriteBufferByParent, + getPendingWriteBufferByPath, + hasPendingWriteBuffers, + type WriteBufferEntry, +} from "./writeBuffer.js"; + +export function findPendingWriteBuffer(db: Database, path: string): WriteBufferEntry | undefined { + if (!hasPendingWriteBuffers(db)) return undefined; + + const { parts, path: canonical } = canonicalizePath(path); + const direct = getPendingWriteBufferByPath(db, canonical); + if (direct !== undefined || parts.length === 0) return direct; + + const leafName = parts.at(-1); + if (leafName === undefined) return undefined; + const parentPath = parts.length === 1 ? "/" : `/${parts.slice(0, -1).join("/")}`; + const parent = resolveInode(db, parentPath); + if (parent?.type !== "dir") return undefined; + return getPendingWriteBufferByParent(db, parent.inode, leafName); +} diff --git a/packages/dofs/src/fs/readFile.ts b/packages/dofs/src/fs/readFile.ts index c690f59e..2abd2ac7 100644 --- a/packages/dofs/src/fs/readFile.ts +++ b/packages/dofs/src/fs/readFile.ts @@ -2,8 +2,9 @@ import { createWorkspaceError } from "../errors.js"; import { canonicalizePath } from "../path.js"; import type { Database } from "../storage.js"; import { getBlobBytes } from "./blobCache.js"; +import { findPendingWriteBuffer } from "./pendingWriteBuffer.js"; import { resolveInode } from "./resolve.js"; -import { getPendingWriteBufferByPath, getWriteBuffer } from "./writeBuffer.js"; +import { getWriteBuffer } from "./writeBuffer.js"; import { CHUNK_SIZE } from "./writeFile.js"; export interface ReadFileOptions { @@ -53,7 +54,7 @@ export async function readFile( // requested window so the returned stream remains a snapshot while writes // continue through the open buffer. const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { return snapshotResult(pending.buf, pending.size, byteOffset, byteLength, wantString); } @@ -211,7 +212,7 @@ export function readRangeSync( // Pending-create files have no inode yet. Serve reads from the // path-keyed buffer until release commits the row. const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { if (length === 0) return new Uint8Array(); if (offset >= pending.size) return new Uint8Array(); diff --git a/packages/dofs/src/fs/resolve.ts b/packages/dofs/src/fs/resolve.ts index defaa66c..5248757c 100644 --- a/packages/dofs/src/fs/resolve.ts +++ b/packages/dofs/src/fs/resolve.ts @@ -199,60 +199,45 @@ function resolveParts( return null; } - let current: NodeRow = root; - for (let i = 0; i < parts.length; i++) { - const isFinal = i === parts.length - 1; - if (current.type !== "dir") { - return null; + const pendingParts = [...parts]; + const nodeStack: NodeRow[] = [root]; + while (pendingParts.length > 0) { + const name = pendingParts.shift(); + if (name === undefined) continue; + const current = nodeStack[nodeStack.length - 1]; + if (current.type !== "dir") return null; + if (name === "" || name === ".") continue; + if (name === "..") { + if (nodeStack.length > 1) nodeStack.pop(); + continue; } + const child = db.one( "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", current.inode, - parts[i], + name, ); - if (child === undefined) { - return null; - } + if (child === undefined) return null; const next = readNode(db, child.child_inode); - if (next === null) { - return null; - } + if (next === null) return null; + // Intermediate symlinks always get followed; final-segment symlinks - // are only followed when the caller wants. A dangling intermediate - // is the same as a missing intermediate (return null). - if (next.type === "symlink" && (!isFinal || followFinal)) { + // are only followed when the caller wants. Keep target components in + // the queue so links before `..` are expanded in filesystem order. + if (next.type === "symlink" && (pendingParts.length > 0 || followFinal)) { follows += 1; if (follows > MAX_SYMLINK_FOLLOWS) { throw createWorkspaceError("ELOOP", "too many symlinks resolving path"); } const target = next.link_target ?? ""; - const resolved = resolveParts(db, canonicalizePath(target).parts, true, follows); - if (resolved === null) { - return null; - } - // Replace the current dirent-resolved node with the followed - // result, then keep walking remaining segments (if any). - current = { - inode: resolved.inode, - type: resolved.type, - mode: resolved.mode, - mtime: resolved.mtime, - size: resolved.size, - link_target: resolved.linkTarget ?? null, - }; + if (target.startsWith("/")) nodeStack.splice(1); + pendingParts.unshift(...target.split("/")); continue; } - current = next; + nodeStack.push(next); } - return { - inode: current.inode, - type: current.type, - mode: current.mode, - mtime: current.mtime, - size: current.size, - linkTarget: current.link_target ?? undefined, - }; + return toResolved(nodeStack[nodeStack.length - 1]); } function readNode(db: Database, inode: number): NodeRow | null { diff --git a/packages/dofs/src/fs/stat.ts b/packages/dofs/src/fs/stat.ts index 4ce2734f..3649a29b 100644 --- a/packages/dofs/src/fs/stat.ts +++ b/packages/dofs/src/fs/stat.ts @@ -1,8 +1,9 @@ import { createWorkspaceError } from "../errors.js"; import { canonicalizePath } from "../path.js"; import type { Database } from "../storage.js"; +import { findPendingWriteBuffer } from "./pendingWriteBuffer.js"; import { resolveInode } from "./resolve.js"; -import { getPendingWriteBufferByPath, getWriteBuffer } from "./writeBuffer.js"; +import { getWriteBuffer } from "./writeBuffer.js"; export interface WorkspaceStatResult { name: string; @@ -39,7 +40,7 @@ function statShared(db: Database, path: string, followFinal: boolean): Workspace // Pending creates never apply to symlinks, so this is safe to run // even on the lstat path — a hit here always corresponds to a // file mid-open. - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined && pending.pending !== undefined) { return { name, diff --git a/packages/dofs/src/fs/symlink.test.ts b/packages/dofs/src/fs/symlink.test.ts index f4317a3e..26b3c598 100644 --- a/packages/dofs/src/fs/symlink.test.ts +++ b/packages/dofs/src/fs/symlink.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { mkdir } from "./mkdir.js"; import { invalidateReadOnlyMountCache } from "./mount-guard.js"; +import { readFile } from "./readFile.js"; import { readlink } from "./readlink.js"; import { resolveInode } from "./resolve.js"; +import { stat } from "./stat.js"; import { symlink } from "./symlink.js"; import { withDB } from "./with-db.js"; import { writeFile } from "./writeFile.js"; @@ -140,6 +142,74 @@ describe("resolveInode + symlinks", () => { }); }); + it("resolves a bare relative target from the symlink parent", async () => { + await withDB(async (db) => { + mkdir(db, "/dir", {}, () => 0); + await writeFile(db, "/dir/target", "hello", {}, () => 0); + symlink(db, "target", "/dir/link", () => 0); + + expect(resolveInode(db, "/dir/link")?.inode).toBe(resolveInode(db, "/dir/target")?.inode); + await expect(readFile(db, "/dir/link", "utf8")).resolves.toBe("hello"); + }); + }); + + it("resolves dot and parent segments in relative symlink targets", async () => { + await withDB(async (db) => { + mkdir(db, "/dir/sub", { recursive: true }, () => 0); + await writeFile(db, "/dir/target", "hello", {}, () => 0); + symlink(db, "./target", "/dir/dot-link", () => 0); + symlink(db, "../target", "/dir/sub/parent-link", () => 0); + + expect(resolveInode(db, "/dir/dot-link")?.inode).toBe(resolveInode(db, "/dir/target")?.inode); + expect(resolveInode(db, "/dir/sub/parent-link")?.inode).toBe( + resolveInode(db, "/dir/target")?.inode, + ); + }); + }); + + it("clamps leading parent segments in relative symlink targets at the root", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "hello", {}, () => 0); + symlink(db, "../../target", "/link", () => 0); + + expect(resolveInode(db, "/link")?.inode).toBe(resolveInode(db, "/target")?.inode); + }); + }); + + it("resolves remaining path segments after a relative symlink", async () => { + await withDB(async (db) => { + mkdir(db, "/dir/real", { recursive: true }, () => 0); + await writeFile(db, "/dir/real/file.txt", "hello", {}, () => 0); + symlink(db, "real", "/dir/link", () => 0); + + expect(resolveInode(db, "/dir/link/file.txt")?.inode).toBe( + resolveInode(db, "/dir/real/file.txt")?.inode, + ); + }); + }); + + it("reports ENOENT for a dangling relative symlink target", async () => { + await withDB((db) => { + symlink(db, "missing", "/dangling", () => 0); + + expect(() => stat(db, "/dangling")).toThrowError(expect.objectContaining({ code: "ENOENT" })); + }); + }); + + it.each(["file/", "file//", "file/../target"])( + "does not traverse past a file in the target %s", + async (target) => { + await withDB(async (db) => { + await writeFile(db, "/file", "file", {}, () => 0); + await writeFile(db, "/target", "target", {}, () => 0); + symlink(db, target, "/link", () => 0); + + expect(resolveInode(db, "/link")).toBeNull(); + await expect(readFile(db, "/link", "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + }, + ); + it("throws ELOOP on a cycle", async () => { await withDB((db) => { symlink(db, "/b", "/a", () => 0); diff --git a/packages/dofs/src/fs/writeBuffer.test.ts b/packages/dofs/src/fs/writeBuffer.test.ts index 891f5e3e..267ccf1d 100644 --- a/packages/dofs/src/fs/writeBuffer.test.ts +++ b/packages/dofs/src/fs/writeBuffer.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import type { Database } from "../storage.js"; +import { mkdir } from "./mkdir.js"; +import { findPendingWriteBuffer } from "./pendingWriteBuffer.js"; import { readRangeSync } from "./readFile.js"; import { resolveInode } from "./resolve.js"; import { stat } from "./stat.js"; +import { symlink } from "./symlink.js"; import { withDB } from "./with-db.js"; import { CHUNK_SIZE, @@ -148,6 +151,20 @@ describe("buffered write lifecycle", () => { }); describe("deferred-create lifecycle", () => { + it("skips parent resolution when no pending buffers exist", async () => { + await withDB((db) => { + const originalAll = db.all.bind(db); + let queries = 0; + db.all = ((query: string, ...bindings: unknown[]) => { + queries += 1; + return originalAll(query, ...bindings); + }) as typeof db.all; + + expect(findPendingWriteBuffer(db, "/missing/file.txt")).toBeUndefined(); + expect(queries).toBe(0); + }); + }); + it("holds the file in memory until release commits one transaction", async () => { await withDB(async (db) => { openWriteBufferForCreateSync(db, "/pending.txt", { mode: 0o600 }, () => 1000); @@ -175,6 +192,60 @@ describe("deferred-create lifecycle", () => { }); }); + it("exposes a symlinked pending create through its real path", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 1000); + symlink(db, "/real", "/linkdir", () => 1000); + expect(resolveInode(db, "/real/pending.txt")).toBeNull(); + + openWriteBufferForCreateSync(db, "/linkdir/pending.txt", {}, () => 1000); + expect(stat(db, "/real/pending.txt").size).toBe(0); + writeRangeSync(db, "/real/pending.txt", bytesOf("hello"), 0, {}, () => 1001); + expect(stat(db, "/real/pending.txt").size).toBe(5); + expect(new TextDecoder().decode(readRangeSync(db, "/real/pending.txt", 0, 5))).toBe("hello"); + releaseWriteBufferSync(db, "/real/pending.txt", () => 1002); + + expect(resolveInode(db, "/real/pending.txt")?.type).toBe("file"); + }); + }); + + it("exposes a pending create through every alias of its parent", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 1000); + symlink(db, "/real", "/a", () => 1000); + symlink(db, "/real", "/b", () => 1000); + openWriteBufferForCreateSync(db, "/a/pending.txt", {}, () => 1000); + + expect(stat(db, "/b/pending.txt").size).toBe(0); + writeRangeSync(db, "/b/pending.txt", bytesOf("hello"), 0, {}, () => 1001); + expect(new TextDecoder().decode(readRangeSync(db, "/b/pending.txt", 0, 5))).toBe("hello"); + releaseWriteBufferSync(db, "/b/pending.txt", () => 1002); + + expect(resolveInode(db, "/real/pending.txt")?.type).toBe("file"); + }); + }); + + it("keeps pending buffer operations free of path lookup queries", async () => { + await withDB((db) => { + mkdir(db, "/deep/real", { recursive: true }, () => 1000); + symlink(db, "/deep/real", "/linkdir", () => 1000); + openWriteBufferForCreateSync(db, "/linkdir/pending.txt", {}, () => 1000); + + const originalOne = db.one.bind(db); + let lookups = 0; + db.one = ((query: string, ...bindings: unknown[]) => { + lookups += 1; + return originalOne(query, ...bindings); + }) as typeof db.one; + + writeRangeSync(db, "/linkdir/pending.txt", bytesOf("hello"), 0, {}, () => 1001); + truncateFileSync(db, "/linkdir/pending.txt", 3, () => 1002); + openWriteBufferSync(db, "/linkdir/pending.txt"); + + expect(lookups).toBe(0); + }); + }); + it("rejects a second openWriteBufferForCreateSync against the same path", async () => { await withDB(async (db) => { openWriteBufferForCreateSync(db, "/dupe.txt", {}, () => 1000); diff --git a/packages/dofs/src/fs/writeBuffer.ts b/packages/dofs/src/fs/writeBuffer.ts index 72ff047a..502de616 100644 --- a/packages/dofs/src/fs/writeBuffer.ts +++ b/packages/dofs/src/fs/writeBuffer.ts @@ -29,6 +29,11 @@ export interface WriteBufferEntry { // Mode the caller wants persisted on release. Defaults to the // inode's existing mode at open time when the caller has none. mode: number; + // Lexical and effective paths used by the most recent successful + // mutation. Dirty release validates these paths rather than whichever + // hardlink alias happens to close last. + dirtyPath?: string; + dirtyTargetPath?: string; // Pending-create state. When set, no inode row exists yet; release // will INSERT the node + dirent + chunks in one transaction. The // synthetic inode id used to key this entry in the cache is stored @@ -38,6 +43,8 @@ export interface WriteBufferEntry { parentInode: number; leafName: string; canonicalPath: string; + resolvedPath: string; + ancestorInodes: number[]; pendingInode: number; mtime: number; }; @@ -46,6 +53,7 @@ export interface WriteBufferEntry { interface DatabaseCache { byInode: Map; byPendingPath: Map; + byPendingParent: Map; nextPendingInode: number; } @@ -54,7 +62,12 @@ const caches = new WeakMap(); function cacheFor(db: Database): DatabaseCache { let cache = caches.get(db); if (cache === undefined) { - cache = { byInode: new Map(), byPendingPath: new Map(), nextPendingInode: -1 }; + cache = { + byInode: new Map(), + byPendingPath: new Map(), + byPendingParent: new Map(), + nextPendingInode: -1, + }; caches.set(db, cache); } return cache; @@ -71,17 +84,33 @@ export function getPendingWriteBufferByPath( return caches.get(db)?.byPendingPath.get(canonicalPath); } +function pendingParentKey(parentInode: number, leafName: string): string { + return `${parentInode}:${leafName}`; +} + +export function getPendingWriteBufferByParent( + db: Database, + parentInode: number, + leafName: string, +): WriteBufferEntry | undefined { + return caches.get(db)?.byPendingParent.get(pendingParentKey(parentInode, leafName)); +} + +export function hasPendingWriteBuffers(db: Database): boolean { + return (caches.get(db)?.byPendingParent.size ?? 0) > 0; +} + // List pending-create buffers whose parent dirent matches `parentInode`. // Used by readdir so freshly-created-but-not-yet-released files show // up in directory listings between open and release. export function listPendingByParent(db: Database, parentInode: number): WriteBufferEntry[] { + return listPendingWriteBuffers(db).filter((entry) => entry.pending?.parentInode === parentInode); +} + +export function listPendingWriteBuffers(db: Database): WriteBufferEntry[] { const cache = caches.get(db); if (cache === undefined) return []; - const out: WriteBufferEntry[] = []; - for (const entry of cache.byPendingPath.values()) { - if (entry.pending?.parentInode === parentInode) out.push(entry); - } - return out; + return [...cache.byInode.values()].filter((entry) => entry.pending !== undefined); } export function setWriteBuffer(db: Database, inode: number, entry: WriteBufferEntry): void { @@ -89,6 +118,11 @@ export function setWriteBuffer(db: Database, inode: number, entry: WriteBufferEn cache.byInode.set(inode, entry); if (entry.pending !== undefined) { cache.byPendingPath.set(entry.pending.canonicalPath, entry); + cache.byPendingPath.set(entry.pending.resolvedPath, entry); + cache.byPendingParent.set( + pendingParentKey(entry.pending.parentInode, entry.pending.leafName), + entry, + ); } } @@ -98,6 +132,10 @@ export function deleteWriteBuffer(db: Database, inode: number): void { const entry = cache.byInode.get(inode); if (entry?.pending !== undefined) { cache.byPendingPath.delete(entry.pending.canonicalPath); + cache.byPendingPath.delete(entry.pending.resolvedPath); + cache.byPendingParent.delete( + pendingParentKey(entry.pending.parentInode, entry.pending.leafName), + ); } cache.byInode.delete(inode); } @@ -122,6 +160,10 @@ export function promotePendingToInode(db: Database, pendingInode: number, realIn if (entry === undefined) return; if (entry.pending !== undefined) { cache.byPendingPath.delete(entry.pending.canonicalPath); + cache.byPendingPath.delete(entry.pending.resolvedPath); + cache.byPendingParent.delete( + pendingParentKey(entry.pending.parentInode, entry.pending.leafName), + ); entry.pending = undefined; } cache.byInode.delete(pendingInode); diff --git a/packages/dofs/src/fs/writeFile.test.ts b/packages/dofs/src/fs/writeFile.test.ts index d8df6b74..98029488 100644 --- a/packages/dofs/src/fs/writeFile.test.ts +++ b/packages/dofs/src/fs/writeFile.test.ts @@ -4,6 +4,7 @@ import { ROOT_INODE } from "../schema/index.js"; import type { Database } from "../storage.js"; import { mkdir } from "./mkdir.js"; import { resolveInode } from "./resolve.js"; +import { symlink } from "./symlink.js"; import { withDB } from "./with-db.js"; import { CHUNK_SIZE, writeFile, writeFileRangesSync, writeFileSync } from "./writeFile.js"; @@ -274,6 +275,16 @@ describe("writeFile", () => { }); }); + it("rejects ENOTDIR when a deep parent path segment is a file", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "file", {}, () => 0); + + await expect( + writeFile(db, "/target/sub/child.txt", "hello", {}, () => 0), + ).rejects.toMatchObject({ code: "ENOTDIR" }); + }); + }); + it("rejects EISDIR when the path resolves to a directory", async () => { await withDB(async (db) => { mkdir(db, "/d", {}, () => 0); @@ -320,6 +331,223 @@ describe("writeFile", () => { }); }); + it("writes through an intermediate symlink to a directory", async () => { + await withDB(async (db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/linkdir", () => 0); + + await writeFile(db, "/linkdir/file.txt", "hello", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/real/file.txt"))).toBe("hello"); + }); + }); + + it("invalidates a cached miss at the resolved path after writing through a symlinked parent", async () => { + await withDB(async (db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/linkdir", () => 0); + expect(resolveInode(db, "/real/file.txt")).toBeNull(); + + await writeFile(db, "/linkdir/file.txt", "hello", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/real/file.txt"))).toBe("hello"); + }); + }); + + it("writes through an intermediate relative symlink to a directory", async () => { + await withDB(async (db) => { + mkdir(db, "/base/real", { recursive: true }, () => 0); + symlink(db, "real", "/base/linkdir", () => 0); + + await writeFile(db, "/base/linkdir/file.txt", "hello", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/base/real/file.txt"))).toBe("hello"); + }); + }); + + it("resolves a relative final symlink from its real parent", async () => { + await withDB(async (db) => { + mkdir(db, "/real/nested", { recursive: true }, () => 0); + symlink(db, "/real/nested", "/alias", () => 0); + symlink(db, "../target", "/real/nested/link", () => 0); + + await writeFile(db, "/alias/link", "hello", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/real/target"))).toBe("hello"); + expect(new TextDecoder().decode(readBack(db, "/alias/link"))).toBe("hello"); + expect(resolveInode(db, "/target")).toBeNull(); + }); + }); + + it("expands a symlink before applying a later parent segment in its target", async () => { + await withDB(async (db) => { + mkdir(db, "/base/dir", { recursive: true }, () => 0); + mkdir(db, "/other/deep", { recursive: true }, () => 0); + symlink(db, "/other/deep", "/base/dir/alias", () => 0); + symlink(db, "alias/../target", "/base/dir/link", () => 0); + + await writeFile(db, "/base/dir/link", "hello", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/other/target"))).toBe("hello"); + expect(new TextDecoder().decode(readBack(db, "/base/dir/link"))).toBe("hello"); + expect(resolveInode(db, "/base/dir/target")).toBeNull(); + }); + }); + + it("rejects ENOTDIR when an intermediate symlink resolves to a file", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "file", {}, () => 0); + symlink(db, "/target", "/linkfile", () => 0); + + await expect( + writeFile(db, "/linkfile/child.txt", "hello", {}, () => 0), + ).rejects.toMatchObject({ + code: "ENOTDIR", + }); + }); + }); + + it("rejects ELOOP when an intermediate symlink is cyclic", async () => { + await withDB(async (db) => { + symlink(db, "/b", "/a", () => 0); + symlink(db, "/a", "/b", () => 0); + + await expect(writeFile(db, "/a/child.txt", "hello", {}, () => 0)).rejects.toMatchObject({ + code: "ELOOP", + }); + }); + }); + + it("counts intermediate and final symlinks against one follow limit", async () => { + await withDB(async (db) => { + mkdir(db, "/real", {}, () => 0); + for (let index = 29; index >= 0; index--) { + const target = index === 29 ? "/real" : `/dir-${index + 1}`; + symlink(db, target, `/dir-${index}`, () => 0); + } + for (let index = 19; index >= 0; index--) { + const target = index === 19 ? "/missing" : `/file-${index + 1}`; + symlink(db, target, `/file-${index}`, () => 0); + } + symlink(db, "/file-0", "/real/link", () => 0); + + await expect(writeFile(db, "/dir-0/link", "hello", {}, () => 0)).rejects.toMatchObject({ + code: "ELOOP", + }); + expect(resolveInode(db, "/missing")).toBeNull(); + }); + }); + + it("writes through a final symlink to its target", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "old", {}, () => 0); + symlink(db, "/target", "/link", () => 0); + + await writeFile(db, "/link", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/target"))).toBe("new"); + expect(new TextDecoder().decode(readBack(db, "/link"))).toBe("new"); + expect( + db.scalar( + "SELECT COUNT(*) FROM vfs_chunks c JOIN vfs_nodes n ON n.inode = c.inode WHERE n.type = 'symlink'", + ), + ).toBe(0); + expect(db.scalar("SELECT size FROM vfs_nodes WHERE type = 'symlink'")).toBe(0); + }); + }); + + it("creates the target when writing through a dangling final symlink", async () => { + await withDB(async (db) => { + symlink(db, "/created", "/link", () => 0); + + await writeFile(db, "/link", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/created"))).toBe("new"); + }); + }); + + it("creates a relative target from the symlink parent when writing through a dangling final symlink", async () => { + await withDB(async (db) => { + mkdir(db, "/dir", {}, () => 0); + symlink(db, "created", "/dir/link", () => 0); + + await writeFile(db, "/dir/link", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/dir/created"))).toBe("new"); + }); + }); + + it("clamps final symlink targets that climb above the root", async () => { + await withDB(async (db) => { + symlink(db, "../../created", "/link", () => 0); + + await writeFile(db, "/link", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/created"))).toBe("new"); + expect(new TextDecoder().decode(readBack(db, "/link"))).toBe("new"); + }); + }); + + it("clamps intermediate symlink targets that climb above the root", async () => { + await withDB(async (db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "../../real", "/linkdir", () => 0); + + await writeFile(db, "/linkdir/file.txt", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/real/file.txt"))).toBe("new"); + }); + }); + + it("creates the missing target at the end of a dangling symlink chain", async () => { + await withDB(async (db) => { + symlink(db, "/mid", "/link", () => 0); + symlink(db, "/missing", "/mid", () => 0); + + await writeFile(db, "/link", "new", {}, () => 0); + + expect(new TextDecoder().decode(readBack(db, "/missing"))).toBe("new"); + expect(resolveInode(db, "/mid", { followSymlinks: false })?.type).toBe("symlink"); + }); + }); + + it("keeps exclusive writes on a final symlink from following the link", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "old", {}, () => 0); + symlink(db, "/target", "/link", () => 0); + + await expect( + writeFile(db, "/link", "new", { exclusive: true }, () => 0), + ).rejects.toMatchObject({ + code: "EEXIST", + }); + expect(new TextDecoder().decode(readBack(db, "/target"))).toBe("old"); + }); + }); + + it("range writes follow a final symlink", async () => { + await withDB(async (db) => { + await writeFile(db, "/target", "abc", {}, () => 0); + symlink(db, "/target", "/link", () => 0); + + writeFileRangesSync( + db, + "/link", + new TextEncoder().encode("axc"), + [{ start: 1, end: 2 }], + {}, + () => 0, + ); + + expect(new TextDecoder().decode(readBack(db, "/target"))).toBe("axc"); + expect( + db.scalar( + "SELECT COUNT(*) FROM vfs_chunks c JOIN vfs_nodes n ON n.inode = c.inode WHERE n.type = 'symlink'", + ), + ).toBe(0); + }); + }); + it("stages blobs incrementally as the stream produces them", async () => { await withDB(async (db) => { // Stream 3 CHUNK_SIZE-aligned source chunks. After the first diff --git a/packages/dofs/src/fs/writeFile.ts b/packages/dofs/src/fs/writeFile.ts index 512e8b4d..34d393c1 100644 --- a/packages/dofs/src/fs/writeFile.ts +++ b/packages/dofs/src/fs/writeFile.ts @@ -6,8 +6,11 @@ import { ROOT_INODE } from "../schema/index.js"; import type { Database } from "../storage.js"; import { stageBlob } from "../sync/blobs.js"; import { buildManifest } from "../sync/manifests.js"; +import { pathOf } from "../sync/paths.js"; import { getBlobBytes } from "./blobCache.js"; -import { assertNotReadOnly } from "./mount-guard.js"; +import { assertNotInReadOnlyMount, assertNotReadOnly } from "./mount-guard.js"; +import { findPendingWriteBuffer } from "./pendingWriteBuffer.js"; +import { resolveInode } from "./resolve.js"; import { invalidateResolveExact } from "./resolveCache.js"; import { allocatePendingInode, @@ -15,6 +18,7 @@ import { ensureCapacity as ensureBufferCapacity, getPendingWriteBufferByPath, getWriteBuffer, + listPendingWriteBuffers, promotePendingToInode, setWriteBuffer, type WriteBufferEntry, @@ -37,13 +41,42 @@ export interface WriteFileRange { end: number; } -// Resolve directory-only paths (the parent of the target file). The -// final segment is handled by the caller. Returns the parent inode or -// throws ENOENT/ENOTDIR. -function resolveParent(db: Database, parts: string[], canonical: string): number { - let parentInode = ROOT_INODE; - for (let i = 0; i < parts.length - 1; i++) { - const name = parts[i]; +interface SymlinkFollowState { + count: number; +} + +interface ResolvedParent { + inode: number; + canonicalPath: string; + ancestorInodes: number[]; +} + +// Resolve the target's parent one component at a time. Expanding links +// here preserves ENOTDIR errors and lets final and intermediate links +// share one follow limit. +function resolveParent( + db: Database, + parts: string[], + canonical: string, + follows: SymlinkFollowState, +): ResolvedParent { + const pendingParts = parts.slice(0, -1); + const inodeStack = [ROOT_INODE]; + const realParts: string[] = []; + const ancestorInodes = new Set([ROOT_INODE]); + + while (pendingParts.length > 0) { + const name = pendingParts.shift(); + if (name === undefined || name === "" || name === ".") continue; + if (name === "..") { + if (inodeStack.length > 1) { + inodeStack.pop(); + realParts.pop(); + } + continue; + } + + const parentInode = inodeStack[inodeStack.length - 1]; const child = db.one<{ child_inode: number }>( "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", parentInode, @@ -52,23 +85,44 @@ function resolveParent(db: Database, parts: string[], canonical: string): number if (child === undefined) { throw createWorkspaceError("ENOENT", `parent directory missing: ${canonical}`, canonical); } - const next = db.one<{ inode: number; type: "file" | "dir" }>( - "SELECT inode, type FROM vfs_nodes WHERE inode = ?", - child.child_inode, - ); - if (next === undefined) { + const node = db.one<{ + inode: number; + type: "file" | "dir" | "symlink"; + link_target: string | null; + }>("SELECT inode, type, link_target FROM vfs_nodes WHERE inode = ?", child.child_inode); + if (node === undefined) { throw createWorkspaceError("ENOENT", `dangling dirent: ${canonical}`, canonical); } - if (next.type !== "dir") { + if (node.type === "symlink") { + ancestorInodes.add(node.inode); + countSymlinkFollow(follows, canonical); + const target = node.link_target ?? ""; + const targetParts = symlinkTargetParts(target, realParts); + assertNotInReadOnlyMount(db, clampedPathFromParts(targetParts)); + if (target.startsWith("/")) { + inodeStack.splice(1); + realParts.splice(0); + } + pendingParts.unshift(...target.split("/")); + continue; + } + if (node.type !== "dir") { throw createWorkspaceError( "ENOTDIR", `parent path segment is not a directory: ${canonical}`, canonical, ); } - parentInode = next.inode; + inodeStack.push(node.inode); + realParts.push(name); + ancestorInodes.add(node.inode); } - return parentInode; + + return { + inode: inodeStack[inodeStack.length - 1], + canonicalPath: pathFromParts(realParts), + ancestorInodes: [...ancestorInodes], + }; } async function materialize(content: string | Uint8Array): Promise { @@ -99,6 +153,148 @@ interface ChunkRef { size: number; } +type WriteTarget = + | { kind: "existing"; inode: number; canonicalPath: string } + | { kind: "create"; parentInode: number; leafName: string; canonicalPath: string }; + +interface DirectWriteTarget { + parentInode: number; + leafName: string; + canonicalPath: string; + ancestorInodes: number[]; + existingInode?: number; +} + +// Match resolveInode's Linux-compatible cap. Final symlinks are +// unwound here because a dangling chain must create its last target. +const MAX_SYMLINK_FOLLOWS = 40; + +function countSymlinkFollow(follows: SymlinkFollowState, path: string): void { + follows.count += 1; + if (follows.count > MAX_SYMLINK_FOLLOWS) { + throw createWorkspaceError("ELOOP", "too many symlinks resolving path", path); + } +} + +function pathFromParts(parts: string[]): string { + return `/${parts.join("/")}`; +} + +function clampedPathFromParts(parts: string[]): string { + const clamped: string[] = []; + for (const part of parts) { + if (part === "" || part === ".") continue; + if (part === "..") { + clamped.pop(); + continue; + } + clamped.push(part); + } + return pathFromParts(clamped); +} + +function childPath(db: Database, parentInode: number, leafName: string, path: string): string { + const parentPath = pathOf(db, parentInode); + if (parentPath === null) { + throw createWorkspaceError("ENOENT", `parent directory missing: ${path}`, path); + } + return parentPath === "/" ? `/${leafName}` : `${parentPath}/${leafName}`; +} + +function pendingTargetPath(entry: WriteBufferEntry, fallback: string): string { + return entry.pending?.resolvedPath ?? fallback; +} + +function symlinkTargetParts(target: string, linkParentParts: string[]): string[] { + const base = target.startsWith("/") ? [] : linkParentParts; + return [...base, ...target.split("/")]; +} + +function resolveDirectWriteTarget( + db: Database, + parts: string[], + canonical: string, + follows: SymlinkFollowState = { count: 0 }, +): DirectWriteTarget { + const parent = resolveParent(db, parts, canonical, follows); + const leafName = parts[parts.length - 1]; + const canonicalPath = + parent.canonicalPath === "/" ? `/${leafName}` : `${parent.canonicalPath}/${leafName}`; + assertNotReadOnly(db, canonicalPath); + const existing = db.one<{ child_inode: number }>( + "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", + parent.inode, + leafName, + ); + return { + parentInode: parent.inode, + leafName, + canonicalPath, + ancestorInodes: parent.ancestorInodes, + existingInode: existing?.child_inode, + }; +} + +function resolveWriteTarget( + db: Database, + parts: string[], + canonical: string, + options: WriteFileOptions, +): WriteTarget { + let targetParts = parts; + let targetCanonical = canonical; + const follows = { count: 0 }; + assertNotReadOnly(db, canonical); + + while (true) { + const direct = resolveDirectWriteTarget(db, targetParts, targetCanonical, follows); + if (direct.existingInode === undefined) { + return { + kind: "create", + parentInode: direct.parentInode, + leafName: direct.leafName, + canonicalPath: direct.canonicalPath, + }; + } + if (options.exclusive) { + throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); + } + + const node = db.one<{ type: "file" | "dir" | "symlink"; link_target: string | null }>( + "SELECT type, link_target FROM vfs_nodes WHERE inode = ?", + direct.existingInode, + ); + if (node === undefined) { + throw createWorkspaceError("ENOENT", `dangling dirent: ${targetCanonical}`, targetCanonical); + } + if (node.type === "dir") { + throw createWorkspaceError( + "EISDIR", + `path is a directory: ${targetCanonical}`, + targetCanonical, + ); + } + if (node.type !== "symlink") { + return { + kind: "existing", + inode: direct.existingInode, + canonicalPath: direct.canonicalPath, + }; + } + + countSymlinkFollow(follows, canonical); + const realLinkParts = canonicalizePath(direct.canonicalPath).parts; + targetParts = symlinkTargetParts(node.link_target ?? "", realLinkParts.slice(0, -1)); + targetCanonical = clampedPathFromParts(targetParts); + assertNotInReadOnlyMount(db, targetCanonical); + const finalPart = targetParts.at(-1); + if (finalPart === undefined || finalPart === "" || finalPart === "." || finalPart === "..") { + resolveParent(db, [...targetParts, "__write_target__"], targetCanonical, follows); + throw createWorkspaceError("EISDIR", "path is a directory", targetCanonical); + } + } +} + export function chunksOf(bytes: Uint8Array): PreparedChunk[] { const chunks: PreparedChunk[] = []; for (let offset = 0; offset < bytes.byteLength; offset += CHUNK_SIZE) { @@ -149,19 +345,9 @@ async function writeFileStreaming( throw createWorkspaceError("EISDIR", "cannot write to the root directory", canonical); } // Reject before we stage any blob bytes so known failures do not grow - // orphan blob rows that gc() then has to reap. - assertNotReadOnly(db, canonical); - if (options.exclusive) { - const parentInode = resolveParent(db, parts, canonical); - const existing = db.one( - "SELECT 1 FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - parts[parts.length - 1], - ); - if (existing !== undefined) { - throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); - } - } + // orphan blob rows that gc() then has to reap. Resolve both lexical and + // effective paths so a symlink cannot defer an EROFS failure until commit. + resolveWriteTarget(db, parts, canonical, options); const mode = (options.mode ?? 0o644) & 0o7777; const mtime = now(); @@ -253,30 +439,12 @@ export function linkStagedChunksSync( assertChunkWindows(chunkRefs, canonical); const mode = (options.mode ?? 0o644) & 0o7777; db.transactionSync(() => { - const parentInode = resolveParent(db, parts, canonical); - const leafName = parts[parts.length - 1]; - const existing = db.one<{ child_inode: number }>( - "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - leafName, - ); - let inode: number; - if (existing !== undefined) { - if (options.exclusive) { - throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); - } - const node = db.one<{ type: "file" | "dir" }>( - "SELECT type FROM vfs_nodes WHERE inode = ?", - existing.child_inode, - ); - if (node?.type === "dir") { - throw createWorkspaceError("EISDIR", `path is a directory: ${canonical}`, canonical); - } - inode = existing.child_inode; + const target = resolveWriteTarget(db, parts, canonical, options); + const inode = target.kind === "existing" ? target.inode : insertFileNode(db, mode, mtime); + if (target.kind === "existing") { db.run("DELETE FROM vfs_chunks WHERE inode = ?", inode); } else { - inode = insertFileNode(db, mode, mtime); - insertFileDirent(db, parentInode, leafName, inode, canonical); + insertFileDirent(db, target.parentInode, target.leafName, inode, target.canonicalPath); } for (let idx = 0; idx < chunkRefs.length; idx++) { const ref = chunkRefs[idx]; @@ -429,17 +597,8 @@ function readChunkBytes(db: Database, inode: number, idx: number): Uint8Array { function resolveFileInode(db: Database, path: string): { inode: number; mode: number } { const { path: canonical } = canonicalizePath(path); - const node = db.one<{ inode: number; type: "file" | "dir"; mode: number }>( - `SELECT n.inode AS inode, n.type AS type, n.mode AS mode - FROM vfs_nodes n - WHERE n.inode = ( - SELECT child_inode - FROM vfs_dirents - WHERE parent_inode = ? AND name = ? - )`, - ...parentAndNameForResolvedPath(db, path), - ); - if (node === undefined) { + const node = resolveInode(db, canonical); + if (node === null) { throw createWorkspaceError("ENOENT", `no such file: ${canonical}`, canonical); } if (node.type !== "file") { @@ -448,12 +607,32 @@ function resolveFileInode(db: Database, path: string): { inode: number; mode: nu return { inode: node.inode, mode: node.mode }; } -function parentAndNameForResolvedPath(db: Database, path: string): [number, string] { +function resolveWritableFileInode( + db: Database, + path: string, +): { inode: number; mode: number; canonicalPath: string } { const { parts, path: canonical } = canonicalizePath(path); if (parts.length === 0) { throw createWorkspaceError("EISDIR", "cannot write to the root directory", canonical); } - return [resolveParent(db, parts, canonical), parts[parts.length - 1]]; + const target = resolveWriteTarget(db, parts, canonical, {}); + if (target.kind === "create") { + throw createWorkspaceError("ENOENT", `no such file: ${canonical}`, canonical); + } + const mode = db.scalar("SELECT mode FROM vfs_nodes WHERE inode = ?", target.inode); + if (mode === undefined) { + throw createWorkspaceError("ENOENT", `no such file: ${canonical}`, canonical); + } + return { inode: target.inode, mode, canonicalPath: target.canonicalPath }; +} + +function directTargetForPath(db: Database, path: string): DirectWriteTarget { + const { parts, path: canonical } = canonicalizePath(path); + if (parts.length === 0) { + throw createWorkspaceError("EISDIR", "cannot write to the root directory", canonical); + } + assertNotReadOnly(db, canonical); + return resolveDirectWriteTarget(db, parts, canonical); } // Update an inode's chunk-backed representation in place. Iterates over @@ -524,18 +703,12 @@ export function createFileSync( now: () => number, ): void { const { path: canonical } = canonicalizePath(path); - assertNotReadOnly(db, canonical); - const [parentInode, leafName] = parentAndNameForResolvedPath(db, path); + const target = directTargetForPath(db, path); const mode = (options.mode ?? 0o644) & 0o7777; const mtime = now(); db.transactionSync(() => { - const existing = db.one<{ child_inode: number }>( - "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - leafName, - ); - if (existing !== undefined) { + if (target.existingInode !== undefined) { throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); } const rev = incrementRev(db); @@ -549,7 +722,7 @@ export function createFileSync( rev, ); if (row === undefined) throw createWorkspaceError("EIO", "failed to allocate inode"); - insertFileDirent(db, parentInode, leafName, row.inode, canonical); + insertFileDirent(db, target.parentInode, target.leafName, row.inode, target.canonicalPath); }); } @@ -559,7 +732,7 @@ export function createFileSync( // the bytes back to chunks. export function openWriteBufferSync(db: Database, path: string): void { const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { pending.openCount += 1; return; @@ -595,17 +768,14 @@ export function openWriteBufferForCreateSync( now: () => number, ): void { const { path: canonical } = canonicalizePath(path); - assertNotReadOnly(db, canonical); if (getPendingWriteBufferByPath(db, canonical) !== undefined) { throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); } - const [parentInode, leafName] = parentAndNameForResolvedPath(db, path); - const existing = db.one<{ child_inode: number }>( - "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - leafName, - ); - if (existing !== undefined) { + const target = directTargetForPath(db, path); + if ( + target.existingInode !== undefined || + getPendingWriteBufferByPath(db, target.canonicalPath) !== undefined + ) { throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); } const mode = (options.mode ?? 0o644) & 0o7777; @@ -617,7 +787,15 @@ export function openWriteBufferForCreateSync( dirty: true, openCount: 1, mode, - pending: { parentInode, leafName, canonicalPath: canonical, pendingInode, mtime }, + pending: { + parentInode: target.parentInode, + leafName: target.leafName, + canonicalPath: canonical, + resolvedPath: target.canonicalPath, + ancestorInodes: target.ancestorInodes, + pendingInode, + mtime, + }, }); } @@ -628,7 +806,7 @@ export function openWriteBufferForCreateSync( // emit their INSERT + dirent + chunks in the same transaction. export function releaseWriteBufferSync(db: Database, path: string, now: () => number): void { const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { releasePendingBuffer(db, pending, now); return; @@ -648,31 +826,41 @@ export function releaseWriteBufferSync(db: Database, path: string, now: () => nu const mode = entry.mode & 0o7777; const buffered = entry.buf.subarray(0, entry.size); - db.transactionSync(() => { - if (entry.size === 0) { - // An empty file owns no chunk rows; clear any old ones the - // buffer would otherwise have replaced and bump metadata. - db.run("DELETE FROM vfs_chunks WHERE inode = ?", node.inode); - const rev = incrementRev(db); - db.run( - "UPDATE vfs_nodes SET mode = ?, mtime = ?, rev = ?, size = 0, manifest_hash = NULL WHERE inode = ?", + try { + if (entry.dirtyPath === undefined || entry.dirtyTargetPath === undefined) { + throw createWorkspaceError("EIO", `buffer has no writable path: ${canonical}`, canonical); + } + assertNotReadOnly(db, entry.dirtyPath); + assertNotReadOnly(db, entry.dirtyTargetPath); + db.transactionSync(() => { + if (entry.size === 0) { + // An empty file owns no chunk rows; clear any old ones the + // buffer would otherwise have replaced and bump metadata. + db.run("DELETE FROM vfs_chunks WHERE inode = ?", node.inode); + const rev = incrementRev(db); + db.run( + "UPDATE vfs_nodes SET mode = ?, mtime = ?, rev = ?, size = 0, manifest_hash = NULL WHERE inode = ?", + mode, + mtime, + rev, + node.inode, + ); + return; + } + applyChunkedInodeUpdate( + db, + node.inode, + entry.size, mode, mtime, - rev, - node.inode, + (_idx, start, end) => start < entry.size && end > 0, + (_idx, start, end) => buffered.subarray(start, Math.min(end, entry.size)), ); - return; - } - applyChunkedInodeUpdate( - db, - node.inode, - entry.size, - mode, - mtime, - (_idx, start, end) => start < entry.size && end > 0, - (_idx, start, end) => buffered.subarray(start, Math.min(end, entry.size)), - ); - }); + }); + } catch (error) { + deleteWriteBuffer(db, node.inode); + throw error; + } deleteWriteBuffer(db, node.inode); } @@ -694,6 +882,9 @@ function commitPendingBuffer(db: Database, entry: WriteBufferEntry, now: () => n let realInode = 0; try { db.transactionSync(() => { + assertNotReadOnly(db, canonicalPath); + const targetPath = childPath(db, parentInode, leafName, canonicalPath); + assertNotReadOnly(db, targetPath); // Re-check at commit time: a non-buffered writeFile or another // out-of-band path could have landed between open and release. const collision = db.one<{ child_inode: number }>( @@ -719,7 +910,7 @@ function commitPendingBuffer(db: Database, entry: WriteBufferEntry, now: () => n if (row === undefined) { throw createWorkspaceError("EIO", "failed to allocate inode"); } - insertFileDirent(db, parentInode, leafName, row.inode, canonicalPath); + insertFileDirent(db, parentInode, leafName, row.inode, targetPath); if (entry.size > 0) { const inode = row.inode; const chunkCount = Math.ceil(entry.size / CHUNK_SIZE); @@ -751,6 +942,7 @@ function commitPendingBuffer(db: Database, entry: WriteBufferEntry, now: () => n throw error; } promotePendingToInode(db, pendingInode, realInode); + entry.dirty = false; return realInode; } @@ -764,12 +956,24 @@ function commitPendingBuffer(db: Database, entry: WriteBufferEntry, now: () => n */ export function flushPendingByPath(db: Database, path: string, now: () => number): boolean { const { path: canonical } = canonicalizePath(path); - const entry = getPendingWriteBufferByPath(db, canonical); + const entry = findPendingWriteBuffer(db, canonical); if (entry === undefined || entry.pending === undefined) return false; commitPendingBuffer(db, entry, now); return true; } +/** @internal Commits pending files reached through a node before its dirent changes. */ +export function flushPendingUnderNode(db: Database, path: string, now: () => number): void { + const node = resolveInode(db, path, { followSymlinks: false }); + if (node === null) return; + + for (const entry of listPendingWriteBuffers(db)) { + if (entry.pending?.ancestorInodes.includes(node.inode)) { + commitPendingBuffer(db, entry, now); + } + } +} + function releasePendingBuffer(db: Database, entry: WriteBufferEntry, now: () => number): void { if (entry.pending === undefined) return; entry.openCount -= 1; @@ -821,8 +1025,9 @@ export function writeRangeSync( // Pending-create files don't have an inode yet; route the write // straight into the path-keyed buffer. - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { + assertNotReadOnly(db, pendingTargetPath(pending, canonical)); const writeEnd = offset + bytes.byteLength; ensureBufferCapacity(pending, writeEnd); if (offset > pending.size) { @@ -835,7 +1040,11 @@ export function writeRangeSync( return bytes.byteLength; } - const { inode, mode: existingMode } = resolveFileInode(db, path); + const { + inode, + mode: existingMode, + canonicalPath: targetPath, + } = resolveWritableFileInode(db, path); const mode = (options.mode ?? existingMode) & 0o7777; const buffered = getWriteBuffer(db, inode); @@ -853,6 +1062,8 @@ export function writeRangeSync( if (writeEnd > buffered.size) buffered.size = writeEnd; buffered.mode = mode; buffered.dirty = true; + buffered.dirtyPath = canonical; + buffered.dirtyTargetPath = targetPath; return bytes.byteLength; } @@ -898,8 +1109,9 @@ export function truncateFileSync( const mtime = now(); // Pending-create files truncate in-place on the path-keyed buffer. - const pending = getPendingWriteBufferByPath(db, canonical); + const pending = findPendingWriteBuffer(db, canonical); if (pending !== undefined) { + assertNotReadOnly(db, pendingTargetPath(pending, canonical)); if (size > pending.size) { ensureBufferCapacity(pending, size); pending.buf.fill(0, pending.size, size); @@ -909,7 +1121,7 @@ export function truncateFileSync( return; } - const { inode, mode } = resolveFileInode(db, path); + const { inode, mode, canonicalPath: targetPath } = resolveWritableFileInode(db, path); const buffered = getWriteBuffer(db, inode); if (buffered !== undefined) { @@ -920,6 +1132,8 @@ export function truncateFileSync( } buffered.size = size; buffered.dirty = true; + buffered.dirtyPath = canonical; + buffered.dirtyTargetPath = targetPath; return; } @@ -975,33 +1189,14 @@ export function writeFileSync( const mtime = now(); db.transactionSync(() => { - const parentInode = resolveParent(db, parts, canonical); - const leafName = parts[parts.length - 1]; - const existing = db.one<{ child_inode: number }>( - "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - leafName, - ); - - let inode: number; - if (existing !== undefined) { - if (options.exclusive) { - throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); - } - const node = db.one<{ type: "file" | "dir" }>( - "SELECT type FROM vfs_nodes WHERE inode = ?", - existing.child_inode, - ); - if (node?.type === "dir") { - throw createWorkspaceError("EISDIR", `path is a directory: ${canonical}`, canonical); - } - inode = existing.child_inode; + const target = resolveWriteTarget(db, parts, canonical, options); + const inode = target.kind === "existing" ? target.inode : insertFileNode(db, mode, mtime); + if (target.kind === "existing") { // Replace the existing representation. Orphaned blobs (if any) // are cleaned up by a later gc() pass. db.run("DELETE FROM vfs_chunks WHERE inode = ?", inode); } else { - inode = insertFileNode(db, mode, mtime); - insertFileDirent(db, parentInode, leafName, inode, canonical); + insertFileDirent(db, target.parentInode, target.leafName, inode, target.canonicalPath); } const rev = incrementRev(db); @@ -1049,29 +1244,13 @@ export function writeFileRangesSync( const ranges = normalizeRanges(dirtyRanges, bytes.byteLength); const mtime = now(); db.transactionSync(() => { - const parentInode = resolveParent(db, parts, canonical); - const leafName = parts[parts.length - 1]; - const existing = db.one<{ child_inode: number }>( - "SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?", - parentInode, - leafName, - ); - - let inode: number; + const target = resolveWriteTarget(db, parts, canonical, options); + const inode = target.kind === "existing" ? target.inode : insertFileNode(db, mode, mtime); let oldChunks: ChunkRef[] = []; - if (existing !== undefined) { - const node = db.one<{ type: "file" | "dir" }>( - "SELECT type FROM vfs_nodes WHERE inode = ?", - existing.child_inode, - ); - if (node?.type === "dir") { - throw createWorkspaceError("EISDIR", `path is a directory: ${canonical}`, canonical); - } - inode = existing.child_inode; + if (target.kind === "existing") { oldChunks = existingChunkRefs(db, inode); } else { - inode = insertFileNode(db, mode, mtime); - insertFileDirent(db, parentInode, leafName, inode, canonical); + insertFileDirent(db, target.parentInode, target.leafName, inode, target.canonicalPath); } const rev = incrementRev(db); diff --git a/packages/dofs/src/fs/writeRange.test.ts b/packages/dofs/src/fs/writeRange.test.ts index 46d08548..3a39ad6b 100644 --- a/packages/dofs/src/fs/writeRange.test.ts +++ b/packages/dofs/src/fs/writeRange.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import type { Database } from "../storage.js"; import { link } from "./link.js"; +import { mkdir } from "./mkdir.js"; import { readFile } from "./readFile.js"; import { resolveInode } from "./resolve.js"; +import { symlink } from "./symlink.js"; import { withDB } from "./with-db.js"; import { CHUNK_SIZE, @@ -73,6 +75,18 @@ describe("direct range writes", () => { }); }); + it("invalidates a cached miss when creating through a symlinked parent", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 1000); + symlink(db, "/real", "/linkdir", () => 1000); + expect(resolveInode(db, "/real/file.txt")).toBeNull(); + + createFileSync(db, "/linkdir/file.txt", {}, () => 1001); + + expect(resolveInode(db, "/real/file.txt")?.type).toBe("file"); + }); + }); + it("writes small ranges and stores them as a single chunk", async () => { await withDB(async (db) => { createFileSync(db, "/small.txt", {}, () => 1000); diff --git a/packages/dofs/src/provider.test.ts b/packages/dofs/src/provider.test.ts index 769086d7..a14a53f6 100644 --- a/packages/dofs/src/provider.test.ts +++ b/packages/dofs/src/provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { invalidateReadOnlyMountCache } from "./fs/mount-guard.js"; import { withDB } from "./fs/with-db.js"; import { SQLiteWorkspaceProvider } from "./provider.js"; import type { Database } from "./storage.js"; @@ -692,6 +693,134 @@ describe("SQLiteWorkspaceProvider — pending-create flush on rename/link/unlink }); }); + it("renameSync commits pending descendants before moving a directory", async () => { + await withProvider((p) => { + p.mkdirSync("/old"); + p.openWriteBufferForCreateSync("/old/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/old/pending.txt", Buffer.from("pending"), 0); + + p.renameSync("/old", "/new"); + + expect((p.readFileSync("/new/pending.txt") as Buffer).toString()).toBe("pending"); + expect(() => + p.openWriteBufferForCreateSync("/new/pending.txt", { mode: 0o644 }), + ).toThrowError(expect.objectContaining({ code: "EEXIST" })); + expect(() => p.releaseWriteBufferSync("/new/pending.txt")).not.toThrow(); + }); + }); + + it("renameSync commits lexical pending descendants reached through symlinks", async () => { + await withProvider((p) => { + p.mkdirSync("/src"); + p.mkdirSync("/outside"); + p.symlinkSync("/outside", "/src/link"); + p.openWriteBufferForCreateSync("/src/link/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/src/link/pending.txt", Buffer.from("pending"), 0); + + p.renameSync("/src", "/new"); + + expect((p.readFileSync("/new/link/pending.txt") as Buffer).toString()).toBe("pending"); + expect(() => p.releaseWriteBufferSync("/new/link/pending.txt")).not.toThrow(); + }); + }); + + it("renameSync commits pending descendants created through another directory alias", async () => { + await withProvider((p) => { + p.mkdirSync("/real/src", { recursive: true }); + p.mkdirSync("/outside"); + p.symlinkSync("/real", "/alias"); + p.symlinkSync("/outside", "/real/src/link"); + p.openWriteBufferForCreateSync("/real/src/link/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/real/src/link/pending.txt", Buffer.from("pending"), 0); + + p.renameSync("/alias/src", "/new"); + + expect((p.readFileSync("/new/link/pending.txt") as Buffer).toString()).toBe("pending"); + expect(() => p.releaseWriteBufferSync("/new/link/pending.txt")).not.toThrow(); + }); + }); + + it("renameSync finds a pending file through another parent alias", async () => { + await withProvider((p) => { + p.mkdirSync("/real"); + p.symlinkSync("/real", "/a"); + p.symlinkSync("/real", "/b"); + p.openWriteBufferForCreateSync("/a/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/a/pending.txt", Buffer.from("pending"), 0); + + p.renameSync("/b/pending.txt", "/moved.txt"); + + expect((p.readFileSync("/moved.txt") as Buffer).toString()).toBe("pending"); + expect(() => p.releaseWriteBufferSync("/moved.txt")).not.toThrow(); + }); + }); + + it("renameSync commits pending files reached through the renamed symlink", async () => { + await withProvider((p) => { + p.mkdirSync("/one"); + p.symlinkSync("/one", "/link"); + p.openWriteBufferForCreateSync("/link/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/link/pending.txt", Buffer.from("pending"), 0); + + p.renameSync("/link", "/newlink"); + + expect((p.readFileSync("/newlink/pending.txt") as Buffer).toString()).toBe("pending"); + expect(() => p.releaseWriteBufferSync("/newlink/pending.txt")).not.toThrow(); + }); + }); + + it("unlinkSync commits pending files before a traversed symlink is replaced", async () => { + await withProvider((p) => { + p.mkdirSync("/one"); + p.mkdirSync("/two"); + p.symlinkSync("/one", "/link"); + p.openWriteBufferForCreateSync("/link/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/link/pending.txt", Buffer.from("pending"), 0); + + p.unlinkSync("/link"); + p.symlinkSync("/two", "/link"); + + expect((p.readFileSync("/one/pending.txt") as Buffer).toString()).toBe("pending"); + expect(p.existsSync("/two/pending.txt")).toBe(false); + expect(() => p.releaseWriteBufferSync("/one/pending.txt")).not.toThrow(); + }); + }); + + it("does not flush an unrelated uncommittable pending create", async () => { + await withProviderAndDB((p, db) => { + p.mkdirSync("/a"); + p.mkdirSync("/b"); + p.openWriteBufferForCreateSync("/a/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/a/pending.txt", Buffer.from("pending"), 0); + db.run( + "INSERT INTO _vfs_mounts (root, kind, indexed, mode) VALUES (?, ?, 1, ?)", + "/a", + "test", + "read-only", + ); + invalidateReadOnlyMountCache(db); + + expect(() => p.renameSync("/b", "/c")).not.toThrow(); + expect(p.existsSync("/b")).toBe(false); + expect(p.existsSync("/c")).toBe(true); + expect((p.readFileSync("/a/pending.txt") as Buffer).toString()).toBe("pending"); + }); + }); + + it("rmdirSync preserves pending descendants", async () => { + await withProvider((p) => { + p.mkdirSync("/dir"); + p.openWriteBufferForCreateSync("/dir/pending.txt", { mode: 0o644 }); + p.writeRangeSync("/dir/pending.txt", Buffer.from("pending"), 0); + + expect(() => p.rmdirSync("/dir")).toThrowError( + expect.objectContaining({ code: "ENOTEMPTY" }), + ); + expect((p.readFileSync("/dir/pending.txt") as Buffer).toString()).toBe("pending"); + expect(() => p.releaseWriteBufferSync("/dir/pending.txt")).not.toThrow(); + }); + }); + it("unlinkSync commits then removes a pending-create file", async () => { await withProvider((p) => { p.openWriteBufferForCreateSync("/gone.txt", { mode: 0o644 }); diff --git a/packages/dofs/src/provider.ts b/packages/dofs/src/provider.ts index 5adcafc8..408d2fcf 100644 --- a/packages/dofs/src/provider.ts +++ b/packages/dofs/src/provider.ts @@ -12,6 +12,7 @@ import { getBlobBytes } from "./fs/blobCache.js"; import { link as linkImpl } from "./fs/link.js"; import type { MkdirOptions } from "./fs/mkdir.js"; import { mkdir as mkdirImpl } from "./fs/mkdir.js"; +import { findPendingWriteBuffer } from "./fs/pendingWriteBuffer.js"; import { readdir as readdirImpl } from "./fs/readdir.js"; import { readRangeSync as readRangeSyncImpl } from "./fs/readFile.js"; import { readlink as readlinkImpl } from "./fs/readlink.js"; @@ -27,14 +28,11 @@ import { type WatchHandle, type WatchOptions, } from "./fs/watch.js"; -import { - deleteWriteBuffer, - getPendingWriteBufferByPath, - getWriteBuffer, -} from "./fs/writeBuffer.js"; +import { deleteWriteBuffer, getWriteBuffer } from "./fs/writeBuffer.js"; import { createFileSync as createFileSyncImpl, flushPendingByPath, + flushPendingUnderNode, openWriteBufferForCreateSync as openWriteBufferForCreateSyncImpl, openWriteBufferSync as openWriteBufferSyncImpl, releaseWriteBufferSync as releaseWriteBufferSyncImpl, @@ -197,8 +195,7 @@ export class SQLiteWorkspaceProvider { } lstatSync(path: string, _options?: { bigint?: boolean }): VirtualStatsLike { - const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(this.db, canonical); + const pending = findPendingWriteBuffer(this.db, path); if (pending !== undefined && pending.pending !== undefined) { return wrapStats({ mode: pending.mode & 0o7777, @@ -263,6 +260,7 @@ export class SQLiteWorkspaceProvider { } rmdirSync(path: string): void { + flushPendingUnderNode(this.db, path, this.now); rmImpl(this.db, path, {}); } @@ -277,6 +275,7 @@ export class SQLiteWorkspaceProvider { // resulting GC sees the orphaned blob, matching the non-buffered // shape). The buffer's open handles continue to address bytes // through the inode-keyed cache. + flushPendingUnderNode(this.db, path, this.now); flushPendingByPath(this.db, path, this.now); // Capture the target inode before rm runs so we can evict its // write-buffer cache entry if rm removed the last link. Without @@ -323,6 +322,8 @@ export class SQLiteWorkspaceProvider { // touches dirents: the source needs a real inode to move, and a // pending buffer at the destination would otherwise slip past // rename's dirent-based existence check and lose bytes on release. + flushPendingUnderNode(this.db, oldPath, this.now); + flushPendingUnderNode(this.db, newPath, this.now); flushPendingByPath(this.db, oldPath, this.now); flushPendingByPath(this.db, newPath, this.now); // Capture the destination inode before the rename so we can evict @@ -356,8 +357,7 @@ export class SQLiteWorkspaceProvider { options?: BufferEncoding | { encoding?: BufferEncoding | null } | null, ): Buffer | string { const encoding = typeof options === "string" ? options : options?.encoding; - const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(this.db, canonical); + const pending = findPendingWriteBuffer(this.db, path); if (pending !== undefined) { const snapshot = Buffer.alloc(pending.size); snapshot.set(pending.buf.subarray(0, pending.size)); @@ -468,8 +468,7 @@ export class SQLiteWorkspaceProvider { } chmodSync(path: string, mode: number): void { - const { path: canonical } = canonicalizePath(path); - const pending = getPendingWriteBufferByPath(this.db, canonical); + const pending = findPendingWriteBuffer(this.db, path); if (pending !== undefined) { // Pending-create files don't have a row yet; stash the mode on // the buffer so the eventual INSERT picks it up. @@ -511,8 +510,7 @@ export class SQLiteWorkspaceProvider { existsSync(path: string): boolean { try { - const { path: canonical } = canonicalizePath(path); - if (getPendingWriteBufferByPath(this.db, canonical) !== undefined) return true; + if (findPendingWriteBuffer(this.db, path) !== undefined) return true; return resolveInode(this.db, path) !== null; } catch { return false;