@mauricio.wolff/mcp-obsidian 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/filesystem.js +45 -66
- package/dist/src/filesystem.test.js +142 -5
- package/dist/src/integration.test.js +229 -0
- package/dist/src/pathfilter.js +8 -7
- package/dist/src/pathfilter.test.js +232 -0
- package/package.json +2 -2
package/dist/src/filesystem.js
CHANGED
|
@@ -44,25 +44,19 @@ export class FileSystemService {
|
|
|
44
44
|
throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
|
|
45
45
|
}
|
|
46
46
|
try {
|
|
47
|
-
try {
|
|
48
|
-
await access(fullPath, constants.F_OK);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
throw new Error(`File not found: ${path}`);
|
|
52
|
-
}
|
|
53
47
|
const content = await readFile(fullPath, 'utf-8');
|
|
54
48
|
return this.frontmatterHandler.parse(content);
|
|
55
49
|
}
|
|
56
50
|
catch (error) {
|
|
57
|
-
if (error instanceof Error) {
|
|
58
|
-
if (error.
|
|
59
|
-
throw
|
|
51
|
+
if (error instanceof Error && 'code' in error) {
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
throw new Error(`File not found: ${path}`);
|
|
60
54
|
}
|
|
61
|
-
if (error.
|
|
55
|
+
if (error.code === 'EACCES') {
|
|
62
56
|
throw new Error(`Permission denied: ${path}`);
|
|
63
57
|
}
|
|
64
|
-
if (error.
|
|
65
|
-
throw
|
|
58
|
+
if (error.code === 'EISDIR') {
|
|
59
|
+
throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
|
|
66
60
|
}
|
|
67
61
|
}
|
|
68
62
|
throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
@@ -303,17 +297,6 @@ export class FileSystemService {
|
|
|
303
297
|
message: `Cannot delete: ${path} is not a file`
|
|
304
298
|
};
|
|
305
299
|
}
|
|
306
|
-
// Check if file exists
|
|
307
|
-
try {
|
|
308
|
-
await access(fullPath, constants.F_OK);
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
return {
|
|
312
|
-
success: false,
|
|
313
|
-
path: path,
|
|
314
|
-
message: `File not found: ${path}`
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
300
|
// Perform the deletion using Node.js native API
|
|
318
301
|
await unlink(fullPath);
|
|
319
302
|
return {
|
|
@@ -367,51 +350,44 @@ export class FileSystemService {
|
|
|
367
350
|
const oldFullPath = this.resolvePath(oldPath);
|
|
368
351
|
const newFullPath = this.resolvePath(newPath);
|
|
369
352
|
try {
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
await access(oldFullPath, constants.F_OK);
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
return {
|
|
376
|
-
success: false,
|
|
377
|
-
oldPath,
|
|
378
|
-
newPath,
|
|
379
|
-
message: `Source file not found: ${oldPath}`
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
// Check if target already exists
|
|
383
|
-
let targetExists = false;
|
|
353
|
+
// Read source content (will throw ENOENT if not found)
|
|
354
|
+
let content;
|
|
384
355
|
try {
|
|
385
|
-
await
|
|
386
|
-
targetExists = true;
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
// Target doesn't exist, which is fine
|
|
356
|
+
content = await readFile(oldFullPath, 'utf-8');
|
|
390
357
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
oldPath,
|
|
363
|
+
newPath,
|
|
364
|
+
message: `Source file not found: ${oldPath}`
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
throw error;
|
|
398
368
|
}
|
|
399
|
-
//
|
|
400
|
-
const content = await readFile(oldFullPath, 'utf-8');
|
|
401
|
-
// Write to new location (create directories if they don't exist)
|
|
369
|
+
// Create directories if needed
|
|
402
370
|
await mkdir(dirname(newFullPath), { recursive: true });
|
|
403
|
-
|
|
404
|
-
// Verify the write was successful
|
|
371
|
+
// Write to new location, checking for existing file atomically if !overwrite
|
|
405
372
|
try {
|
|
406
|
-
|
|
373
|
+
if (overwrite) {
|
|
374
|
+
await writeFile(newFullPath, content, 'utf-8');
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// wx flag: write exclusive - fails if file exists
|
|
378
|
+
await writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' });
|
|
379
|
+
}
|
|
407
380
|
}
|
|
408
|
-
catch {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
383
|
+
return {
|
|
384
|
+
success: false,
|
|
385
|
+
oldPath,
|
|
386
|
+
newPath,
|
|
387
|
+
message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
throw error;
|
|
415
391
|
}
|
|
416
392
|
// Delete the source file
|
|
417
393
|
await unlink(oldFullPath);
|
|
@@ -497,13 +473,16 @@ export class FileSystemService {
|
|
|
497
473
|
throw new Error(`Access denied: ${path}`);
|
|
498
474
|
}
|
|
499
475
|
const fullPath = this.resolvePath(path);
|
|
476
|
+
let stats;
|
|
500
477
|
try {
|
|
501
|
-
await
|
|
478
|
+
stats = await stat(fullPath);
|
|
502
479
|
}
|
|
503
|
-
catch {
|
|
504
|
-
|
|
480
|
+
catch (error) {
|
|
481
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
482
|
+
throw new Error(`File not found: ${path}`);
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
505
485
|
}
|
|
506
|
-
const stats = await stat(fullPath);
|
|
507
486
|
const size = stats.size;
|
|
508
487
|
const lastModified = stats.mtime.getTime();
|
|
509
488
|
// Quick check for frontmatter without reading full content
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { FileSystemService } from "./filesystem.js";
|
|
3
|
-
import { writeFile, mkdir,
|
|
3
|
+
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
let testVaultPath;
|
|
6
7
|
let fileSystem;
|
|
7
8
|
beforeEach(async () => {
|
|
8
|
-
await
|
|
9
|
+
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-test-"));
|
|
9
10
|
fileSystem = new FileSystemService(testVaultPath);
|
|
10
11
|
});
|
|
11
12
|
afterEach(async () => {
|
|
12
13
|
try {
|
|
13
|
-
await
|
|
14
|
+
await rm(testVaultPath, { recursive: true });
|
|
14
15
|
}
|
|
15
|
-
catch
|
|
16
|
+
catch {
|
|
16
17
|
// Ignore cleanup errors
|
|
17
18
|
}
|
|
18
19
|
});
|
|
@@ -513,3 +514,139 @@ test("frontmatter validation with invalid data", async () => {
|
|
|
513
514
|
}
|
|
514
515
|
})).rejects.toThrow(/Invalid frontmatter/);
|
|
515
516
|
});
|
|
517
|
+
// ============================================================================
|
|
518
|
+
// NON-EXISTENT VAULT TESTS
|
|
519
|
+
// ============================================================================
|
|
520
|
+
test("read from non-existent vault throws error", async () => {
|
|
521
|
+
const nonExistentFs = new FileSystemService("/non/existent/vault/path");
|
|
522
|
+
await expect(nonExistentFs.readNote("test.md"))
|
|
523
|
+
.rejects.toThrow(/File not found|ENOENT/);
|
|
524
|
+
});
|
|
525
|
+
test("write to non-existent vault creates directories", async () => {
|
|
526
|
+
const tempVault = await mkdtemp(join(tmpdir(), "mcp-obsidian-new-vault-"));
|
|
527
|
+
const newFs = new FileSystemService(tempVault);
|
|
528
|
+
try {
|
|
529
|
+
await newFs.writeNote({
|
|
530
|
+
path: "new-folder/nested/note.md",
|
|
531
|
+
content: "Test content"
|
|
532
|
+
});
|
|
533
|
+
const note = await newFs.readNote("new-folder/nested/note.md");
|
|
534
|
+
expect(note.content).toContain("Test content");
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
await rm(tempVault, { recursive: true });
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
test("list directory in non-existent vault", async () => {
|
|
541
|
+
const nonExistentFs = new FileSystemService("/non/existent/vault/path");
|
|
542
|
+
await expect(nonExistentFs.listDirectory("/"))
|
|
543
|
+
.rejects.toThrow();
|
|
544
|
+
});
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// PATH TRAVERSAL WITH SPECIAL CHARACTERS
|
|
547
|
+
// ============================================================================
|
|
548
|
+
test("path traversal attempt with encoded dots blocked", async () => {
|
|
549
|
+
// Path traversal should be blocked even with URL encoding
|
|
550
|
+
await expect(fileSystem.readNote("..%2F..%2Fetc%2Fpasswd"))
|
|
551
|
+
.rejects.toThrow(/Path traversal not allowed/);
|
|
552
|
+
});
|
|
553
|
+
test("path traversal with .. is blocked", async () => {
|
|
554
|
+
await expect(fileSystem.readNote("../outside.md"))
|
|
555
|
+
.rejects.toThrow(/Path traversal not allowed/);
|
|
556
|
+
});
|
|
557
|
+
test("path traversal with nested .. is blocked", async () => {
|
|
558
|
+
await expect(fileSystem.readNote("folder/../../outside.md"))
|
|
559
|
+
.rejects.toThrow(/Path traversal not allowed/);
|
|
560
|
+
});
|
|
561
|
+
test("path with regex special chars is treated literally", async () => {
|
|
562
|
+
const testPath = "folder (copy)/note [1].md";
|
|
563
|
+
const content = "# Test with special chars";
|
|
564
|
+
await mkdir(join(testVaultPath, "folder (copy)"), { recursive: true });
|
|
565
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
566
|
+
const note = await fileSystem.readNote(testPath);
|
|
567
|
+
expect(note.content).toContain("Test with special chars");
|
|
568
|
+
});
|
|
569
|
+
test("path with dollar sign works", async () => {
|
|
570
|
+
const testPath = "$special/price$100.md";
|
|
571
|
+
const content = "# Price note";
|
|
572
|
+
await mkdir(join(testVaultPath, "$special"), { recursive: true });
|
|
573
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
574
|
+
const note = await fileSystem.readNote(testPath);
|
|
575
|
+
expect(note.content).toContain("Price note");
|
|
576
|
+
});
|
|
577
|
+
test("path with plus sign works", async () => {
|
|
578
|
+
const testPath = "C++/notes.md";
|
|
579
|
+
const content = "# C++ notes";
|
|
580
|
+
await mkdir(join(testVaultPath, "C++"), { recursive: true });
|
|
581
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
582
|
+
const note = await fileSystem.readNote(testPath);
|
|
583
|
+
expect(note.content).toContain("C++ notes");
|
|
584
|
+
});
|
|
585
|
+
test("path with pipe character works", async () => {
|
|
586
|
+
const testPath = "choice|option.md";
|
|
587
|
+
const content = "# Choice note";
|
|
588
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
589
|
+
const note = await fileSystem.readNote(testPath);
|
|
590
|
+
expect(note.content).toContain("Choice note");
|
|
591
|
+
});
|
|
592
|
+
test("delete note with special chars in path", async () => {
|
|
593
|
+
const testPath = "folder (archive)/note [old].md";
|
|
594
|
+
const content = "# Old note";
|
|
595
|
+
await mkdir(join(testVaultPath, "folder (archive)"), { recursive: true });
|
|
596
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
597
|
+
const result = await fileSystem.deleteNote({
|
|
598
|
+
path: testPath,
|
|
599
|
+
confirmPath: testPath
|
|
600
|
+
});
|
|
601
|
+
expect(result.success).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
test("move note with special chars in both paths", async () => {
|
|
604
|
+
const oldPath = "source (1)/note [a].md";
|
|
605
|
+
const newPath = "dest (2)/note [b].md";
|
|
606
|
+
const content = "# Moving note";
|
|
607
|
+
await mkdir(join(testVaultPath, "source (1)"), { recursive: true });
|
|
608
|
+
await mkdir(join(testVaultPath, "dest (2)"), { recursive: true });
|
|
609
|
+
await writeFile(join(testVaultPath, oldPath), content);
|
|
610
|
+
const result = await fileSystem.moveNote({
|
|
611
|
+
oldPath,
|
|
612
|
+
newPath
|
|
613
|
+
});
|
|
614
|
+
expect(result.success).toBe(true);
|
|
615
|
+
const note = await fileSystem.readNote(newPath);
|
|
616
|
+
expect(note.content).toContain("Moving note");
|
|
617
|
+
});
|
|
618
|
+
test("patch note with regex special chars in oldString", async () => {
|
|
619
|
+
const testPath = "regex-test.md";
|
|
620
|
+
const content = "Price: $10.50 (discount)";
|
|
621
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
622
|
+
const result = await fileSystem.patchNote({
|
|
623
|
+
path: testPath,
|
|
624
|
+
oldString: "$10.50 (discount)",
|
|
625
|
+
newString: "$15.00 (regular)",
|
|
626
|
+
replaceAll: false
|
|
627
|
+
});
|
|
628
|
+
expect(result.success).toBe(true);
|
|
629
|
+
const note = await fileSystem.readNote(testPath);
|
|
630
|
+
expect(note.content).toContain("$15.00 (regular)");
|
|
631
|
+
});
|
|
632
|
+
// Note: searchNotes is in SearchService, not FileSystemService
|
|
633
|
+
// Search tests with regex special chars should be in search.test.ts
|
|
634
|
+
// ============================================================================
|
|
635
|
+
// UNICODE AND INTERNATIONAL PATHS
|
|
636
|
+
// ============================================================================
|
|
637
|
+
test("handles unicode in file paths", async () => {
|
|
638
|
+
const testPath = "日本語/ノート.md";
|
|
639
|
+
const content = "# Japanese note";
|
|
640
|
+
await mkdir(join(testVaultPath, "日本語"), { recursive: true });
|
|
641
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
642
|
+
const note = await fileSystem.readNote(testPath);
|
|
643
|
+
expect(note.content).toContain("Japanese note");
|
|
644
|
+
});
|
|
645
|
+
test("handles emoji in file paths", async () => {
|
|
646
|
+
const testPath = "📁/🎉.md";
|
|
647
|
+
const content = "# Emoji note";
|
|
648
|
+
await mkdir(join(testVaultPath, "📁"), { recursive: true });
|
|
649
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
650
|
+
const note = await fileSystem.readNote(testPath);
|
|
651
|
+
expect(note.content).toContain("Emoji note");
|
|
652
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, describe } from "vitest";
|
|
2
|
+
import { FileSystemService } from "./filesystem.js";
|
|
3
|
+
import { FrontmatterHandler } from "./frontmatter.js";
|
|
4
|
+
import { PathFilter } from "./pathfilter.js";
|
|
5
|
+
import { SearchService } from "./search.js";
|
|
6
|
+
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
let testVaultPath;
|
|
10
|
+
let pathFilter;
|
|
11
|
+
let frontmatterHandler;
|
|
12
|
+
let fileSystem;
|
|
13
|
+
let searchService;
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-integration-"));
|
|
16
|
+
// Initialize services (same as server.ts)
|
|
17
|
+
pathFilter = new PathFilter();
|
|
18
|
+
frontmatterHandler = new FrontmatterHandler();
|
|
19
|
+
fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
|
|
20
|
+
searchService = new SearchService(testVaultPath, pathFilter);
|
|
21
|
+
});
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
try {
|
|
24
|
+
await rm(testVaultPath, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore cleanup errors
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// INTEGRATION TESTS - END-TO-END WORKFLOW
|
|
32
|
+
// ============================================================================
|
|
33
|
+
describe("Integration: Service Layer Workflows", () => {
|
|
34
|
+
test("write, read, and delete note workflow", async () => {
|
|
35
|
+
// 1. Write a note with frontmatter
|
|
36
|
+
await fileSystem.writeNote({
|
|
37
|
+
path: "test-note.md",
|
|
38
|
+
content: "# Test Note\n\nThis is a test.",
|
|
39
|
+
frontmatter: { tags: ["test"], status: "draft" }
|
|
40
|
+
});
|
|
41
|
+
// 2. Read the note back
|
|
42
|
+
const note = await fileSystem.readNote("test-note.md");
|
|
43
|
+
expect(note.content).toContain("This is a test");
|
|
44
|
+
expect(note.frontmatter?.tags).toEqual(["test"]);
|
|
45
|
+
expect(note.frontmatter?.status).toBe("draft");
|
|
46
|
+
// 3. Delete the note
|
|
47
|
+
const deleteResult = await fileSystem.deleteNote({
|
|
48
|
+
path: "test-note.md",
|
|
49
|
+
confirmPath: "test-note.md"
|
|
50
|
+
});
|
|
51
|
+
expect(deleteResult.success).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
test("search notes with special characters in filenames", async () => {
|
|
54
|
+
// Create notes with special characters in paths
|
|
55
|
+
const testCases = [
|
|
56
|
+
{ path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
|
|
57
|
+
{ path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
|
|
58
|
+
{ path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
|
|
59
|
+
{ path: "price$100.md", content: "# Pricing\n\nCost keyword." }
|
|
60
|
+
];
|
|
61
|
+
// Write all test notes
|
|
62
|
+
for (const { path, content } of testCases) {
|
|
63
|
+
if (path.includes('/')) {
|
|
64
|
+
const dirName = path.split('/')[0];
|
|
65
|
+
if (dirName) {
|
|
66
|
+
await mkdir(join(testVaultPath, dirName), { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await writeFile(join(testVaultPath, path), content);
|
|
70
|
+
}
|
|
71
|
+
// Search for keyword
|
|
72
|
+
const results = await searchService.search({
|
|
73
|
+
query: "keyword",
|
|
74
|
+
limit: 10
|
|
75
|
+
});
|
|
76
|
+
expect(results.length).toBe(4);
|
|
77
|
+
// Verify paths with special characters are returned correctly
|
|
78
|
+
const paths = results.map((r) => r.p);
|
|
79
|
+
expect(paths).toContain("folder (archive)/note [old].md");
|
|
80
|
+
expect(paths).toContain("C++/notes.md");
|
|
81
|
+
});
|
|
82
|
+
test("write note with regex special chars in content", async () => {
|
|
83
|
+
const content = `# Price List
|
|
84
|
+
|
|
85
|
+
Item: Widget ($10.50)
|
|
86
|
+
Regex: [a-z]+ matches lowercase
|
|
87
|
+
Math: 2 + 2 = 4
|
|
88
|
+
Pattern: backup.2024/**/*.md`;
|
|
89
|
+
await fileSystem.writeNote({
|
|
90
|
+
path: "special-chars.md",
|
|
91
|
+
content
|
|
92
|
+
});
|
|
93
|
+
// Read back and verify exact content
|
|
94
|
+
const note = await fileSystem.readNote("special-chars.md");
|
|
95
|
+
expect(note.content).toContain("($10.50)");
|
|
96
|
+
expect(note.content).toContain("[a-z]+");
|
|
97
|
+
expect(note.content).toContain("2 + 2 = 4");
|
|
98
|
+
expect(note.content).toContain("backup.2024/**/*.md");
|
|
99
|
+
});
|
|
100
|
+
test("unicode and emoji in paths and content", async () => {
|
|
101
|
+
// Create folders with unicode
|
|
102
|
+
await mkdir(join(testVaultPath, "日本語"), { recursive: true });
|
|
103
|
+
await mkdir(join(testVaultPath, "📁"), { recursive: true });
|
|
104
|
+
const testCases = [
|
|
105
|
+
{ path: "日本語/ノート.md", content: "# 日本語のメモ\n\nこんにちは世界" },
|
|
106
|
+
{ path: "📁/🎉.md", content: "# Celebration\n\n🎊 Party time! 🎈" }
|
|
107
|
+
];
|
|
108
|
+
// Write notes
|
|
109
|
+
for (const { path, content } of testCases) {
|
|
110
|
+
await fileSystem.writeNote({ path, content });
|
|
111
|
+
}
|
|
112
|
+
// Read back and verify
|
|
113
|
+
for (const { path, content } of testCases) {
|
|
114
|
+
const note = await fileSystem.readNote(path);
|
|
115
|
+
expect(note.content).toBe(content);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
test("security: path traversal blocked", async () => {
|
|
119
|
+
const maliciousPaths = [
|
|
120
|
+
"../etc/passwd",
|
|
121
|
+
"../../secret.txt",
|
|
122
|
+
"folder/../../../outside.md"
|
|
123
|
+
];
|
|
124
|
+
for (const path of maliciousPaths) {
|
|
125
|
+
await expect(fileSystem.readNote(path))
|
|
126
|
+
.rejects.toThrow(/Path traversal not allowed|Access denied/);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
test("security: blocked directories not accessible", async () => {
|
|
130
|
+
// Try to access .obsidian
|
|
131
|
+
await expect(fileSystem.readNote(".obsidian/app.json"))
|
|
132
|
+
.rejects.toThrow(/Access denied/);
|
|
133
|
+
// Try to access .git
|
|
134
|
+
await expect(fileSystem.readNote(".git/config"))
|
|
135
|
+
.rejects.toThrow(/Access denied/);
|
|
136
|
+
});
|
|
137
|
+
test("multi-step workflow: search, read multiple, update frontmatter", async () => {
|
|
138
|
+
// Create several notes
|
|
139
|
+
for (let i = 1; i <= 3; i++) {
|
|
140
|
+
await fileSystem.writeNote({
|
|
141
|
+
path: `note-${i}.md`,
|
|
142
|
+
content: `# Note ${i}\n\nThis contains searchterm.`,
|
|
143
|
+
frontmatter: { id: i, processed: false }
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Search for notes
|
|
147
|
+
const searchResults = await searchService.search({
|
|
148
|
+
query: "searchterm",
|
|
149
|
+
limit: 10
|
|
150
|
+
});
|
|
151
|
+
expect(searchResults.length).toBe(3);
|
|
152
|
+
// Read multiple notes
|
|
153
|
+
const paths = searchResults.map(r => r.p);
|
|
154
|
+
const readResult = await fileSystem.readMultipleNotes({
|
|
155
|
+
paths,
|
|
156
|
+
includeContent: true,
|
|
157
|
+
includeFrontmatter: true
|
|
158
|
+
});
|
|
159
|
+
expect(readResult.successful.length).toBe(3);
|
|
160
|
+
// Update frontmatter on all notes
|
|
161
|
+
for (const path of paths) {
|
|
162
|
+
await fileSystem.updateFrontmatter({
|
|
163
|
+
path,
|
|
164
|
+
frontmatter: { processed: true },
|
|
165
|
+
merge: true
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Verify updates
|
|
169
|
+
for (const path of paths) {
|
|
170
|
+
const note = await fileSystem.readNote(path);
|
|
171
|
+
expect(note.frontmatter?.processed).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// PERFORMANCE TESTS
|
|
177
|
+
// ============================================================================
|
|
178
|
+
describe("Performance: Post-PR#12 Overhead", () => {
|
|
179
|
+
test("pathfilter performance with many checks", () => {
|
|
180
|
+
const filter = new PathFilter();
|
|
181
|
+
const testPaths = [
|
|
182
|
+
"notes/daily/2024-01-01.md",
|
|
183
|
+
"projects/work/report (final).md",
|
|
184
|
+
"archive [2023]/old-note.md",
|
|
185
|
+
"C++/algorithms/sort.md",
|
|
186
|
+
"backup.2024/data.md",
|
|
187
|
+
".obsidian/app.json",
|
|
188
|
+
".git/config",
|
|
189
|
+
"node_modules/package/index.js"
|
|
190
|
+
];
|
|
191
|
+
const start = performance.now();
|
|
192
|
+
// Run 1000 iterations
|
|
193
|
+
for (let i = 0; i < 1000; i++) {
|
|
194
|
+
for (const path of testPaths) {
|
|
195
|
+
filter.isAllowed(path);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const duration = performance.now() - start;
|
|
199
|
+
// Should complete in reasonable time (< 100ms for 8000 checks)
|
|
200
|
+
expect(duration).toBeLessThan(100);
|
|
201
|
+
});
|
|
202
|
+
test("large batch operations performance", async () => {
|
|
203
|
+
// Create 50 notes
|
|
204
|
+
const paths = [];
|
|
205
|
+
for (let i = 1; i <= 50; i++) {
|
|
206
|
+
const path = `batch/note-${i}.md`;
|
|
207
|
+
paths.push(path);
|
|
208
|
+
}
|
|
209
|
+
await mkdir(join(testVaultPath, "batch"), { recursive: true });
|
|
210
|
+
for (const path of paths) {
|
|
211
|
+
await writeFile(join(testVaultPath, path), `# Note\n\nContent for ${path}`);
|
|
212
|
+
}
|
|
213
|
+
const start = performance.now();
|
|
214
|
+
// Read all 50 notes (max batch size is 10, so this tests multiple batches)
|
|
215
|
+
const batches = [];
|
|
216
|
+
for (let i = 0; i < paths.length; i += 10) {
|
|
217
|
+
const batchPaths = paths.slice(i, i + 10);
|
|
218
|
+
batches.push(fileSystem.readMultipleNotes({
|
|
219
|
+
paths: batchPaths,
|
|
220
|
+
includeContent: true,
|
|
221
|
+
includeFrontmatter: true
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
await Promise.all(batches);
|
|
225
|
+
const duration = performance.now() - start;
|
|
226
|
+
// Should complete in reasonable time (< 500ms for 50 files)
|
|
227
|
+
expect(duration).toBeLessThan(500);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/dist/src/pathfilter.js
CHANGED
|
@@ -18,13 +18,14 @@ export class PathFilter {
|
|
|
18
18
|
];
|
|
19
19
|
}
|
|
20
20
|
simpleGlobMatch(pattern, path) {
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.replace(
|
|
26
|
-
.replace(
|
|
27
|
-
.replace(
|
|
21
|
+
// Normalize pattern path separators (Windows compatibility)
|
|
22
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
23
|
+
// Convert glob pattern to regex, escaping special regex chars first
|
|
24
|
+
let regexPattern = normalizedPattern
|
|
25
|
+
.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') // Escape all regex special chars
|
|
26
|
+
.replace(/\\\*\\\*/g, '.*') // ** matches any number of directories (unescape)
|
|
27
|
+
.replace(/\\\*/g, '[^/]*') // * matches anything except / (unescape)
|
|
28
|
+
.replace(/\\\?/g, '[^/]'); // ? matches single character except / (unescape)
|
|
28
29
|
// Ensure we match the full path
|
|
29
30
|
regexPattern = '^' + regexPattern + '$';
|
|
30
31
|
const regex = new RegExp(regexPattern);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { PathFilter } from "./pathfilter.js";
|
|
3
|
+
describe("PathFilter", () => {
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// BASIC FUNCTIONALITY
|
|
6
|
+
// ============================================================================
|
|
7
|
+
test("allows markdown files by default", () => {
|
|
8
|
+
const filter = new PathFilter();
|
|
9
|
+
expect(filter.isAllowed("notes/test.md")).toBe(true);
|
|
10
|
+
expect(filter.isAllowed("test.markdown")).toBe(true);
|
|
11
|
+
expect(filter.isAllowed("folder/subfolder/note.txt")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
test("blocks .obsidian directory", () => {
|
|
14
|
+
const filter = new PathFilter();
|
|
15
|
+
expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
|
|
16
|
+
expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
test("blocks .git directory", () => {
|
|
19
|
+
const filter = new PathFilter();
|
|
20
|
+
expect(filter.isAllowed(".git/config")).toBe(false);
|
|
21
|
+
expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test("blocks node_modules", () => {
|
|
24
|
+
const filter = new PathFilter();
|
|
25
|
+
expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
test("blocks system files", () => {
|
|
28
|
+
const filter = new PathFilter();
|
|
29
|
+
expect(filter.isAllowed(".DS_Store")).toBe(false);
|
|
30
|
+
expect(filter.isAllowed("Thumbs.db")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
test("blocks non-allowed extensions", () => {
|
|
33
|
+
const filter = new PathFilter();
|
|
34
|
+
expect(filter.isAllowed("script.js")).toBe(false);
|
|
35
|
+
expect(filter.isAllowed("data.json")).toBe(false);
|
|
36
|
+
expect(filter.isAllowed("image.png")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// REGEX SPECIAL CHARACTERS - SECURITY TESTS
|
|
40
|
+
// ============================================================================
|
|
41
|
+
describe("regex special characters in paths", () => {
|
|
42
|
+
test("handles dots in filenames literally", () => {
|
|
43
|
+
const filter = new PathFilter();
|
|
44
|
+
// Dots should be literal, not regex wildcards
|
|
45
|
+
expect(filter.isAllowed("file.name.md")).toBe(true);
|
|
46
|
+
expect(filter.isAllowed("v1.0.0-notes.md")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
test("handles parentheses in paths", () => {
|
|
49
|
+
const filter = new PathFilter();
|
|
50
|
+
expect(filter.isAllowed("notes/(archived)/old.md")).toBe(true);
|
|
51
|
+
expect(filter.isAllowed("project (copy).md")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
test("handles square brackets in paths", () => {
|
|
54
|
+
const filter = new PathFilter();
|
|
55
|
+
expect(filter.isAllowed("notes/[2024]/january.md")).toBe(true);
|
|
56
|
+
expect(filter.isAllowed("[inbox]/task.md")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
test("handles curly braces in paths", () => {
|
|
59
|
+
const filter = new PathFilter();
|
|
60
|
+
expect(filter.isAllowed("templates/{daily}.md")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
test("handles plus signs in paths", () => {
|
|
63
|
+
const filter = new PathFilter();
|
|
64
|
+
expect(filter.isAllowed("C++/notes.md")).toBe(true);
|
|
65
|
+
expect(filter.isAllowed("topic+subtopic.md")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
test("handles question marks in paths", () => {
|
|
68
|
+
const filter = new PathFilter();
|
|
69
|
+
// Question mark is a glob wildcard, but in actual filenames should work
|
|
70
|
+
expect(filter.isAllowed("FAQ?.md")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
test("handles asterisks in filenames", () => {
|
|
73
|
+
const filter = new PathFilter();
|
|
74
|
+
// Asterisk in filename (rare but valid on Unix)
|
|
75
|
+
expect(filter.isAllowed("important*.md")).toBe(true);
|
|
76
|
+
expect(filter.isAllowed("file*name.md")).toBe(true);
|
|
77
|
+
expect(filter.isAllowed("notes/todo*.md")).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
test("asterisk in custom ignored pattern works as glob", () => {
|
|
80
|
+
const filter = new PathFilter({
|
|
81
|
+
ignoredPatterns: ["temp*/**"]
|
|
82
|
+
});
|
|
83
|
+
// Pattern uses * as wildcard - should match temp, temp1, temporary, etc.
|
|
84
|
+
expect(filter.isAllowed("temp/file.md")).toBe(false);
|
|
85
|
+
expect(filter.isAllowed("temp1/file.md")).toBe(false);
|
|
86
|
+
expect(filter.isAllowed("temporary/file.md")).toBe(false);
|
|
87
|
+
// Should NOT match "atemp" (pattern starts with temp)
|
|
88
|
+
expect(filter.isAllowed("atemp/file.md")).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
test("double asterisk ** matches nested paths", () => {
|
|
91
|
+
const filter = new PathFilter({
|
|
92
|
+
ignoredPatterns: ["archive/**"]
|
|
93
|
+
});
|
|
94
|
+
expect(filter.isAllowed("archive/old.md")).toBe(false);
|
|
95
|
+
expect(filter.isAllowed("archive/2024/jan/note.md")).toBe(false);
|
|
96
|
+
expect(filter.isAllowed("other/archive/note.md")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
test("handles pipe character in paths", () => {
|
|
99
|
+
const filter = new PathFilter();
|
|
100
|
+
expect(filter.isAllowed("option|choice.md")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
test("handles caret in paths", () => {
|
|
103
|
+
const filter = new PathFilter();
|
|
104
|
+
expect(filter.isAllowed("version^2.md")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
test("handles dollar sign in paths", () => {
|
|
107
|
+
const filter = new PathFilter();
|
|
108
|
+
expect(filter.isAllowed("price$100.md")).toBe(true);
|
|
109
|
+
expect(filter.isAllowed("$HOME/notes.md")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
test("handles backslash (Windows paths)", () => {
|
|
112
|
+
const filter = new PathFilter();
|
|
113
|
+
// Backslashes should be normalized to forward slashes
|
|
114
|
+
expect(filter.isAllowed("folder\\subfolder\\note.md")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// CUSTOM IGNORED PATTERNS WITH SPECIAL CHARS
|
|
119
|
+
// ============================================================================
|
|
120
|
+
describe("custom patterns with special characters", () => {
|
|
121
|
+
test("custom pattern with dots is treated literally", () => {
|
|
122
|
+
const filter = new PathFilter({
|
|
123
|
+
ignoredPatterns: ["backup.2024/**"]
|
|
124
|
+
});
|
|
125
|
+
expect(filter.isAllowed("backup.2024/notes.md")).toBe(false);
|
|
126
|
+
// "backup_2024" should NOT match "backup.2024" pattern
|
|
127
|
+
expect(filter.isAllowed("backup_2024/notes.md")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
test("custom pattern with parentheses works", () => {
|
|
130
|
+
const filter = new PathFilter({
|
|
131
|
+
ignoredPatterns: ["(archive)/**"]
|
|
132
|
+
});
|
|
133
|
+
expect(filter.isAllowed("(archive)/old.md")).toBe(false);
|
|
134
|
+
expect(filter.isAllowed("archive/old.md")).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
test("custom pattern with brackets works", () => {
|
|
137
|
+
const filter = new PathFilter({
|
|
138
|
+
ignoredPatterns: ["[trash]/**"]
|
|
139
|
+
});
|
|
140
|
+
expect(filter.isAllowed("[trash]/deleted.md")).toBe(false);
|
|
141
|
+
expect(filter.isAllowed("trash/deleted.md")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// PATH TRAVERSAL ATTEMPTS
|
|
146
|
+
// ============================================================================
|
|
147
|
+
describe("path traversal prevention", () => {
|
|
148
|
+
test("blocks obvious traversal patterns", () => {
|
|
149
|
+
const filter = new PathFilter({
|
|
150
|
+
ignoredPatterns: ["../**"]
|
|
151
|
+
});
|
|
152
|
+
expect(filter.isAllowed("../secret.md")).toBe(false);
|
|
153
|
+
expect(filter.isAllowed("../../etc/passwd")).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
test("handles encoded traversal attempts", () => {
|
|
156
|
+
const filter = new PathFilter();
|
|
157
|
+
// These should be allowed by PathFilter (path validation is in FileSystem)
|
|
158
|
+
// but filter shouldn't crash on unusual characters
|
|
159
|
+
expect(() => filter.isAllowed("%2e%2e/secret.md")).not.toThrow();
|
|
160
|
+
expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// FILTER PATHS BATCH OPERATION
|
|
165
|
+
// ============================================================================
|
|
166
|
+
describe("filterPaths", () => {
|
|
167
|
+
test("filters array of paths correctly", () => {
|
|
168
|
+
const filter = new PathFilter();
|
|
169
|
+
const paths = [
|
|
170
|
+
"notes/valid.md",
|
|
171
|
+
".obsidian/config.json",
|
|
172
|
+
"archive/old.md",
|
|
173
|
+
".git/HEAD",
|
|
174
|
+
"readme.txt"
|
|
175
|
+
];
|
|
176
|
+
const allowed = filter.filterPaths(paths);
|
|
177
|
+
expect(allowed).toEqual([
|
|
178
|
+
"notes/valid.md",
|
|
179
|
+
"archive/old.md",
|
|
180
|
+
"readme.txt"
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
test("handles empty array", () => {
|
|
184
|
+
const filter = new PathFilter();
|
|
185
|
+
expect(filter.filterPaths([])).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
test("handles array with all blocked paths", () => {
|
|
188
|
+
const filter = new PathFilter();
|
|
189
|
+
const paths = [
|
|
190
|
+
".obsidian/app.json",
|
|
191
|
+
".git/config",
|
|
192
|
+
"node_modules/pkg/index.js"
|
|
193
|
+
];
|
|
194
|
+
expect(filter.filterPaths(paths)).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// EDGE CASES
|
|
199
|
+
// ============================================================================
|
|
200
|
+
describe("edge cases", () => {
|
|
201
|
+
test("handles empty path", () => {
|
|
202
|
+
const filter = new PathFilter();
|
|
203
|
+
expect(() => filter.isAllowed("")).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
test("handles path with only extension", () => {
|
|
206
|
+
const filter = new PathFilter();
|
|
207
|
+
expect(filter.isAllowed(".md")).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
test("handles very long paths", () => {
|
|
210
|
+
const filter = new PathFilter();
|
|
211
|
+
const longPath = "a/".repeat(100) + "note.md";
|
|
212
|
+
expect(() => filter.isAllowed(longPath)).not.toThrow();
|
|
213
|
+
expect(filter.isAllowed(longPath)).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
test("handles unicode characters in paths", () => {
|
|
216
|
+
const filter = new PathFilter();
|
|
217
|
+
expect(filter.isAllowed("notes/日本語.md")).toBe(true);
|
|
218
|
+
expect(filter.isAllowed("émojis/🎉.md")).toBe(true);
|
|
219
|
+
expect(filter.isAllowed("中文/笔记.md")).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
test("handles spaces in paths", () => {
|
|
222
|
+
const filter = new PathFilter();
|
|
223
|
+
expect(filter.isAllowed("my notes/important file.md")).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
test("handles directories (no extension)", () => {
|
|
226
|
+
const filter = new PathFilter();
|
|
227
|
+
// Directories should be allowed (no extension check)
|
|
228
|
+
expect(filter.isAllowed("folder/subfolder/")).toBe(true);
|
|
229
|
+
expect(filter.isAllowed("notes")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mauricio.wolff/mcp-obsidian",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
|
|
5
5
|
"author": "bitbonsai",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@types/node": "^20.19.21",
|
|
35
35
|
"tsx": "^4.20.6",
|
|
36
36
|
"typescript": "^5.9.3",
|
|
37
|
-
"vitest": "^
|
|
37
|
+
"vitest": "^4.0.15"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
40
|
"node": ">=18.0.0"
|