Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Create missing parent directories while applying sync entries.
23 changes: 21 additions & 2 deletions packages/dofs/src/fs/mkdir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/dofs/src/fs/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
309 changes: 309 additions & 0 deletions packages/dofs/src/sync/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,315 @@ 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("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("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) => {
await withDB(async (db) => {
if (kind === "file") {
await writeFile(db, "/workspace", "old", {}, () => 1);
} else {
symlink(db, "/missing", "/workspace", () => 1);
}

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(
"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("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(
Expand Down
Loading
Loading