@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.
@@ -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.message.includes('File not found')) {
59
- throw error;
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.message.includes('permission') || error.message.includes('access')) {
55
+ if (error.code === 'EACCES') {
62
56
  throw new Error(`Permission denied: ${path}`);
63
57
  }
64
- if (error.message.includes('Cannot read directory')) {
65
- throw error;
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
- // Check if source file exists
371
- try {
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 access(newFullPath, constants.F_OK);
386
- targetExists = true;
387
- }
388
- catch {
389
- // Target doesn't exist, which is fine
356
+ content = await readFile(oldFullPath, 'utf-8');
390
357
  }
391
- if (targetExists && !overwrite) {
392
- return {
393
- success: false,
394
- oldPath,
395
- newPath,
396
- message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
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
- // Read source content
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
- await writeFile(newFullPath, content, 'utf-8');
404
- // Verify the write was successful
371
+ // Write to new location, checking for existing file atomically if !overwrite
405
372
  try {
406
- await access(newFullPath, constants.F_OK);
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
- return {
410
- success: false,
411
- oldPath,
412
- newPath,
413
- message: `Failed to create target file: ${newPath}`
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 access(fullPath, constants.F_OK);
478
+ stats = await stat(fullPath);
502
479
  }
503
- catch {
504
- throw new Error(`File not found: ${path}`);
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, rmdir } from "fs/promises";
3
+ import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
4
4
  import { join } from "path";
5
- const testVaultPath = "/tmp/test-vault-filesystem";
5
+ import { tmpdir } from "os";
6
+ let testVaultPath;
6
7
  let fileSystem;
7
8
  beforeEach(async () => {
8
- await mkdir(testVaultPath, { recursive: true });
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 rmdir(testVaultPath, { recursive: true });
14
+ await rm(testVaultPath, { recursive: true });
14
15
  }
15
- catch (error) {
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
+ });
@@ -18,13 +18,14 @@ export class PathFilter {
18
18
  ];
19
19
  }
20
20
  simpleGlobMatch(pattern, path) {
21
- // Convert glob pattern to regex
22
- // Handle ** (any number of directories)
23
- let regexPattern = pattern
24
- .replace(/\*\*/g, '.*') // ** matches any number of directories
25
- .replace(/\*/g, '[^/]*') // * matches anything except /
26
- .replace(/\?/g, '[^/]') // ? matches single character except /
27
- .replace(/\./g, '\\.'); // Escape dots
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.0",
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": "^1.6.1"
37
+ "vitest": "^4.0.15"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=18.0.0"