Skip to content

Commit bd7538e

Browse files
committed
feat(vscode): support drag-and-drop, file creation, rename, and delete
Fix three regressions introduced when workspace folders switched from file:// to obs:// scheme: - File creation via "New File" failed with EntryNotFound because validatePath called realpath() on non-existent paths. Add validatePathForWrite() that walks up to the nearest existing ancestor for the symlink check, and auto-create parent directories in writeFile. - Drag-and-drop from local folders into the VFS was broken. Implement copy() on the FileSystemProvider for Workspace Explorer drops, and add VaultTreeDragAndDropController for sidebar tree drops. - Moving files out of the vault and deleting vault entries threw "Not supported on obs:// scheme". Implement rename() with same-scheme fs.rename and cross-scheme copy+delete, and implement delete() with path-validated rm(). Also add getParent() to VaultTreeDataProvider for proper drop-target resolution in nested trees. Assisted-by: Claude
1 parent 4aa6858 commit bd7538e

15 files changed

Lines changed: 716 additions & 26 deletions

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@obsidian-vfs/cli",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"type": "module",
55
"description": "CLI for Obsidian VFS — provision skills/agents, inspect vault resources",
66
"license": "Apache-2.0",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@obsidian-vfs/core",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"type": "module",
55
"description": "Core library for Obsidian VFS — vault resolution, content processing, path security",
66
"license": "Apache-2.0",

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export { EXIT_SUCCESS, EXIT_ERROR, EXIT_USAGE } from "./exit-codes.js";
8383
*/
8484
export {
8585
validatePath,
86+
validatePathForWrite,
8687
canonicalizePath,
8788
isAllowedPath,
8889
checkBlockedFolder,

packages/core/src/path-security.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
checkSymlink,
88
isAllowedPath,
99
validatePath,
10+
validatePathForWrite,
1011
} from "./path-security.js";
1112
import { mockFsFunction } from "./test-helpers.js";
1213

@@ -320,3 +321,91 @@ describe("validatePath", () => {
320321
}
321322
});
322323
});
324+
325+
describe("validatePathForWrite", () => {
326+
beforeEach(() => {
327+
vi.clearAllMocks();
328+
});
329+
330+
it("returns validated path when file exists", async () => {
331+
realpathMock.mockResolvedValue("/vault/notes/foo.md");
332+
const result = await validatePathForWrite("notes/foo.md", EMPTY_OPTS);
333+
expect(result).toEqual({ ok: true, value: "/vault/notes/foo.md" });
334+
});
335+
336+
it("succeeds for non-existent file in existing directory", async () => {
337+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
338+
enoent.code = "ENOENT";
339+
realpathMock
340+
.mockRejectedValueOnce(enoent) // /vault/notes/new.md does not exist
341+
.mockResolvedValueOnce("/vault/notes"); // /vault/notes exists
342+
const result = await validatePathForWrite("notes/new.md", EMPTY_OPTS);
343+
expect(result).toEqual({ ok: true, value: "/vault/notes/new.md" });
344+
});
345+
346+
it("succeeds for non-existent nested directories", async () => {
347+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
348+
enoent.code = "ENOENT";
349+
realpathMock
350+
.mockRejectedValueOnce(enoent) // /vault/a/b/c.md
351+
.mockRejectedValueOnce(enoent) // /vault/a/b
352+
.mockRejectedValueOnce(enoent) // /vault/a
353+
.mockResolvedValueOnce("/vault"); // /vault exists
354+
const result = await validatePathForWrite("a/b/c.md", EMPTY_OPTS);
355+
expect(result).toEqual({ ok: true, value: "/vault/a/b/c.md" });
356+
});
357+
358+
it("fails on symlink escape in ancestor", async () => {
359+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
360+
enoent.code = "ENOENT";
361+
realpathMock
362+
.mockRejectedValueOnce(enoent) // /vault/link/new.md
363+
.mockResolvedValueOnce("/outside"); // /vault/link resolves outside
364+
const result = await validatePathForWrite("link/new.md", EMPTY_OPTS);
365+
expect(result.ok).toBe(false);
366+
if (!result.ok) {
367+
expect(result.error.code).toBe("PERMISSION_DENIED");
368+
}
369+
});
370+
371+
it("fails on path traversal", async () => {
372+
const result = await validatePathForWrite("../../etc/passwd", EMPTY_OPTS);
373+
expect(result.ok).toBe(false);
374+
if (!result.ok) {
375+
expect(result.error.code).toBe("PERMISSION_DENIED");
376+
}
377+
expect(realpathMock).not.toHaveBeenCalled();
378+
});
379+
380+
it("fails on blocked folder", async () => {
381+
const result = await validatePathForWrite("private/new.md", {
382+
...EMPTY_OPTS,
383+
blocked: ["private"],
384+
});
385+
expect(result.ok).toBe(false);
386+
if (!result.ok) {
387+
expect(result.error.code).toBe("PERMISSION_DENIED");
388+
}
389+
expect(realpathMock).not.toHaveBeenCalled();
390+
});
391+
392+
it("fails on allowed folder violation", async () => {
393+
const result = await validatePathForWrite("other/new.md", {
394+
...EMPTY_OPTS,
395+
allowed: ["notes"],
396+
});
397+
expect(result.ok).toBe(false);
398+
if (!result.ok) {
399+
expect(result.error.code).toBe("PERMISSION_DENIED");
400+
}
401+
expect(realpathMock).not.toHaveBeenCalled();
402+
});
403+
404+
it("returns canonicalized path, not realpath of ancestor", async () => {
405+
const enoent = new Error("ENOENT") as NodeJS.ErrnoException;
406+
enoent.code = "ENOENT";
407+
realpathMock.mockRejectedValueOnce(enoent).mockResolvedValueOnce("/vault/notes");
408+
const result = await validatePathForWrite("notes/new.md", EMPTY_OPTS);
409+
expect(result).toEqual({ ok: true, value: "/vault/notes/new.md" });
410+
});
411+
});

packages/core/src/path-security.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,29 @@ export async function validatePath(
135135

136136
return checkSymlink(canonical.value, options.vaultRoot);
137137
}
138+
139+
/**
140+
* Like `validatePath` but for write operations where the target may not exist.
141+
* Walks up to the nearest existing ancestor for the symlink check instead of
142+
* requiring the full path to exist. Returns the canonicalized absolute path.
143+
*/
144+
export async function validatePathForWrite(
145+
virtualPath: string,
146+
options: PathSecurityOptions,
147+
): Promise<VFSResult<string>> {
148+
const canonical = canonicalizePath(virtualPath, options.vaultRoot);
149+
if (!canonical.ok) return canonical;
150+
151+
const allowed = checkAllowedFolder(canonical.value, options);
152+
if (!allowed.ok) return allowed;
153+
154+
let ancestor = canonical.value;
155+
for (;;) {
156+
const result = await checkSymlink(ancestor, options.vaultRoot);
157+
if (result.ok) return { ok: true, value: canonical.value };
158+
if (result.error.code !== "FILE_NOT_FOUND") return result;
159+
const parent = path.dirname(ancestor);
160+
if (parent === ancestor) return result;
161+
ancestor = parent;
162+
}
163+
}

packages/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "obsidian-vfs",
33
"displayName": "Obsidian VFS",
44
"description": "Browse, search, and edit your Obsidian vault directly in VSCode via a virtual file system (obs://)",
5-
"version": "0.2.2",
5+
"version": "0.2.3",
66
"private": true,
77
"publisher": "otaviof",
88
"engines": {

packages/vscode/src/extension.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ vi.mock("./vault-tree-provider.js", () => ({
4646
}),
4747
}));
4848

49+
vi.mock("./tree-drag-drop.js", () => ({
50+
VaultTreeDragAndDropController: vi.fn(),
51+
}));
52+
4953
vi.mock("./status-bar.js", () => ({
5054
StatusBarManager: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
5155
this.show = vi.fn();
@@ -81,6 +85,7 @@ import { FOLDER_NAME_PREFIX } from "./workspace-folder.js";
8185
import { ObsidianFileSystemProvider } from "./file-system-provider.js";
8286
import { StatusBarManager } from "./status-bar.js";
8387
import { VaultTreeDataProvider } from "./vault-tree-provider.js";
88+
import { VaultTreeDragAndDropController } from "./tree-drag-drop.js";
8489
import { WikilinkDocumentLinkProvider } from "./wikilink-provider.js";
8590
import {
8691
addVaultWorkspaceFolder,
@@ -161,7 +166,11 @@ describe("activate", () => {
161166
{ isCaseSensitive: true, isReadonly: false },
162167
);
163168
expect(VaultTreeDataProvider).toHaveBeenCalledWith(fakeTracker);
164-
expect(vscode.window.createTreeView).toHaveBeenCalledWith("obsidianVFS", expect.anything());
169+
expect(VaultTreeDragAndDropController).toHaveBeenCalledWith("MyVault");
170+
expect(vscode.window.createTreeView).toHaveBeenCalledWith(
171+
"obsidianVFS",
172+
expect.objectContaining({ dragAndDropController: expect.anything() }),
173+
);
165174
const treeView = vi.mocked(vscode.window.createTreeView).mock.results[0].value as {
166175
title: string;
167176
};

packages/vscode/src/extension.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SCHEME } from "./uri-adapter.js";
55
import { registerCommands } from "./commands.js";
66
import { ObsidianFileSystemProvider } from "./file-system-provider.js";
77
import { StatusBarManager } from "./status-bar.js";
8+
import { VaultTreeDragAndDropController } from "./tree-drag-drop.js";
89
import { VaultTreeDataProvider } from "./vault-tree-provider.js";
910
import { WikilinkDocumentLinkProvider } from "./wikilink-provider.js";
1011
import {
@@ -50,7 +51,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
5051

5152
const treeProvider = new VaultTreeDataProvider(tracker);
5253
context.subscriptions.push(treeProvider);
53-
const treeView = vscode.window.createTreeView("obsidianVFS", { treeDataProvider: treeProvider });
54+
const dragAndDropController = new VaultTreeDragAndDropController(tracker.context.name);
55+
const treeView = vscode.window.createTreeView("obsidianVFS", {
56+
treeDataProvider: treeProvider,
57+
dragAndDropController,
58+
});
5459
const cfg = vscode.workspace.getConfiguration("obsidianVFS");
5560
treeView.title =
5661
cfg.get<string>("treeViewTitle", "") || `${FOLDER_NAME_PREFIX}${tracker.context.name}`;

0 commit comments

Comments
 (0)