From 967ff18d8d6e6b7a3f4fd044043dea1c11f764a2 Mon Sep 17 00:00:00 2001 From: Riccardo Date: Thu, 25 Sep 2025 17:42:44 +0200 Subject: [PATCH] Sanitize filepath in template before note creation (#1520) fixes #1216 --- .../src/services/note-creation-engine.test.ts | 128 ++++++++++++++++++ .../src/services/note-creation-engine.ts | 25 +++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/foam-vscode/src/services/note-creation-engine.test.ts b/packages/foam-vscode/src/services/note-creation-engine.test.ts index 95b1f36f..b2562daf 100644 --- a/packages/foam-vscode/src/services/note-creation-engine.test.ts +++ b/packages/foam-vscode/src/services/note-creation-engine.test.ts @@ -474,4 +474,132 @@ Content without filepath metadata.`, }); }); }); + + describe('filepath sanitization', () => { + it('should sanitize invalid characters in filepath from template', async () => { + const { engine } = await setupFoamEngine(); + + const template: Template = { + type: 'markdown', + content: `--- +foam_template: + filepath: \${FOAM_TITLE}.md +--- +# \${FOAM_TITLE}`, + metadata: new Map(), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + // Title with many invalid characters (excluding / which is preserved for directories): \#%&{}<>?*$!'":@+`|= + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'Test\\#%&{}<>?*$!\'"Title:@+`|='); + + const result = await engine.processTemplate(trigger, template, resolver); + + // All invalid characters should become dashes: Test + 14 invalid chars + Title + : + @+`|= (6 more total) + expect(result.filepath.path).toBe('Test--------------Title------.md'); + + // Content should remain unchanged + expect(result.content).toContain('# Test\\#%&{}<>?*$!\'"Title:@+`|='); + }); + + it('should not affect FOAM_TITLE when not used in filepath', async () => { + const { engine } = await setupFoamEngine(); + + // Template with static filepath, FOAM_TITLE only in content + const template: Template = { + type: 'markdown', + content: `--- +foam_template: + filepath: notes/static-file.md +--- +# \${FOAM_TITLE} + +Content with \${FOAM_TITLE} should remain unchanged.`, + metadata: new Map(), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'Invalid "Characters" '); + + const result = await engine.processTemplate(trigger, template, resolver); + + // Filepath should remain static (no sanitization needed) + expect(result.filepath.path).toBe('notes/static-file.md'); + + // Content should use original FOAM_TITLE with invalid characters + expect(result.content).toContain('# Invalid "Characters" '); + expect(result.content).toContain( + 'Content with Invalid "Characters" should remain' + ); + }); + + it('should sanitize complex filepath patterns with multiple variables', async () => { + const { engine } = await setupFoamEngine(); + + const template: Template = { + type: 'markdown', + content: `--- +foam_template: + filepath: \${FOAM_DATE_YEAR}/\${FOAM_DATE_MONTH}/\${FOAM_TITLE}.md +--- +# \${FOAM_TITLE} + +Date and title combination.`, + metadata: new Map(), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const testDate = new Date('2024-03-15'); + const resolver = new Resolver(new Map(), testDate); + resolver.define('FOAM_TITLE', 'Note:With|Invalid*Chars'); + resolver.define('FOAM_DATE_YEAR', '2024'); + resolver.define('FOAM_DATE_MONTH', '03'); + + const result = await engine.processTemplate(trigger, template, resolver); + + // Entire resolved filepath should be sanitized + expect(result.filepath.path).toBe('2024/03/Note-With-Invalid-Chars.md'); + + // Content should use original FOAM_TITLE + expect(result.content).toContain('# Note:With|Invalid*Chars'); + }); + + it('should handle filepath with no invalid characters', async () => { + const { engine } = await setupFoamEngine(); + + const template: Template = { + type: 'markdown', + content: `--- +foam_template: + filepath: notes/\${FOAM_TITLE}.md +--- +# \${FOAM_TITLE}`, + metadata: new Map(), + }; + + const trigger = TriggerFactory.createCommandTrigger( + 'foam-vscode.create-note' + ); + + const resolver = new Resolver(new Map(), new Date()); + resolver.define('FOAM_TITLE', 'ValidTitle123'); + + const result = await engine.processTemplate(trigger, template, resolver); + + // No sanitization needed - should remain unchanged + expect(result.filepath.path).toBe('notes/ValidTitle123.md'); + expect(result.content).toContain('# ValidTitle123'); + }); + }); }); diff --git a/packages/foam-vscode/src/services/note-creation-engine.ts b/packages/foam-vscode/src/services/note-creation-engine.ts index 8d3b28d4..72811e31 100644 --- a/packages/foam-vscode/src/services/note-creation-engine.ts +++ b/packages/foam-vscode/src/services/note-creation-engine.ts @@ -12,6 +12,26 @@ import { import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser'; import { URI } from '../core/model/uri'; +/** + * Characters that are invalid in file names + * Based on UNALLOWED_CHARS from variable-resolver.ts but excluding forward slash + * which is needed for directory separators in filepaths + */ +const FILEPATH_UNALLOWED_CHARS = '\\#%&{}<>?*$!\'":@+`|='; + +/** + * Sanitizes a filepath by replacing invalid characters with dashes + * Note: Forward slashes (/) are preserved for directory separators + * @param filepath The filepath to sanitize + * @returns The sanitized filepath + */ +function sanitizeFilepath(filepath: string): string { + // Escape special regex characters and create character class + const escapedChars = FILEPATH_UNALLOWED_CHARS.replace(/[\\^\-\]]/g, '\\$&'); + const regex = new RegExp(`[${escapedChars}]`, 'g'); + return filepath.replace(regex, '-'); +} + /** * Unified engine for creating notes from both Markdown and JavaScript templates */ @@ -109,10 +129,13 @@ export class NoteCreationEngine { ]); // Determine filepath - get variables from resolver for default generation - const filepath = + let filepath = metadata.get('filepath') ?? (await this.generateDefaultFilepath(resolver)); + // Sanitize the filepath to remove invalid characters + filepath = sanitizeFilepath(filepath); + return { filepath: this.roots[0].forPath(filepath), content: cleanContent,