Skip to content

Commit 0ce8e74

Browse files
authored
fix: regular expression templating issue when using special character for example in windows (#32)
1 parent e75b7c7 commit 0ce8e74

File tree

8 files changed

+483
-17
lines changed

8 files changed

+483
-17
lines changed

schemas/hub-config.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "http://json-schema.org/draft-07/schema#",
3-
"$id": "https://github.com/wherka/prompt-registry/schemas/hub-config.schema.json",
3+
"$id": "https://github.com/AmadeusITGroup/prompt-registry/blob/main/schemas/hub-config.schema.json",
44
"title": "Hub Configuration Schema",
55
"description": "JSON Schema for Prompt Registry hub configuration files",
66
"type": "object",

src/services/CopilotSyncService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as path from 'path';
1919
import { promisify } from 'util';
2020
import * as yaml from 'js-yaml';
2121
import { Logger } from '../utils/logger';
22+
import { escapeRegex } from '../utils/regexUtils';
2223
import { DeploymentManifest } from '../types/registry';
2324

2425
const readFile = promisify(fs.readFile);
@@ -74,7 +75,8 @@ export class CopilotSyncService {
7475
const baseDir = path.dirname(path.dirname(globalStoragePath));
7576

7677
// Check if we're in a profiles structure
77-
const profilesMatch = baseDir.match(new RegExp(`profiles${path.sep}([^${path.sep}]+)`));
78+
const escapedSep = escapeRegex(path.sep);
79+
const profilesMatch = baseDir.match(new RegExp(`profiles${escapedSep}([^${escapedSep}]+)`));
7880
if (profilesMatch) {
7981
const profileId = profilesMatch[1];
8082
const profileName = this.getActiveProfileName(baseDir) || profileId;
@@ -91,7 +93,8 @@ export class CopilotSyncService {
9193
// Check if this is a profile-based path by looking for '/profiles/' after User
9294
// Path structure: .../User/profiles/<profile-id>/globalStorage/...
9395
const remainingPath = globalStoragePath.substring(userDir.length);
94-
const profilesMatch = remainingPath.match(new RegExp(`^${path.sep}profiles${path.sep}([^${path.sep}]+)`));
96+
const escapedSep = escapeRegex(path.sep);
97+
const profilesMatch = remainingPath.match(new RegExp(`^${escapedSep}profiles${escapedSep}([^${escapedSep}]+)`));
9598

9699
if (profilesMatch) {
97100
// Profile-based path: include the profile directory

src/services/PromptExecutor.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import * as vscode from 'vscode';
77
import { Logger } from '../utils/logger';
8+
import { replaceVariables } from '../utils/regexUtils';
89

910
export interface PromptExecutionOptions {
1011
promptContent: string;
@@ -142,13 +143,11 @@ export class PromptExecutor {
142143
variables: Record<string, string>,
143144
options: PromptExecutionOptions
144145
): Promise<void> {
145-
// Substitute template variables
146-
let processedPrompt = promptTemplate;
147-
148-
for (const [key, value] of Object.entries(variables)) {
149-
const pattern = new RegExp(`\\{${key}\\}`, 'g');
150-
processedPrompt = processedPrompt.replace(pattern, value);
151-
}
146+
// Substitute template variables using safe regex utility
147+
const processedPrompt = replaceVariables(promptTemplate, variables, {
148+
prefix: '{',
149+
suffix: '}'
150+
});
152151

153152
// Execute with processed prompt
154153
await this.execute({

src/services/TemplateEngine.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
33
import { Logger } from '../utils/logger';
4+
import { replaceVariables } from '../utils/regexUtils';
45

56
export interface TemplateContext {
67
projectName: string;
@@ -73,11 +74,8 @@ export class TemplateEngine {
7374
// Enhance context with computed values
7475
const enhancedContext = this.enhanceContext(context);
7576

76-
// Substitute variables
77-
for (const [key, value] of Object.entries(enhancedContext)) {
78-
const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
79-
content = content.replace(placeholder, value);
80-
}
77+
// Substitute variables using safe regex utility
78+
content = replaceVariables(content, enhancedContext);
8179

8280
return content;
8381
}

src/services/githubService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as semver from 'semver';
44
import { GitHubRelease, GitHubAsset, VersionInfo, BundleInfo } from '../types/github';
55
import { Platform } from '../types/platform';
66
import { Logger } from '../utils/logger';
7+
import { escapeRegex } from '../utils/regexUtils';
78
import { exec } from 'child_process';
89
import { promisify } from 'util';
910

@@ -302,7 +303,10 @@ export class GitHubService {
302303
*/
303304
public findPlatformBundle(release: GitHubRelease, platform: Platform): BundleInfo | null {
304305
const platformPrefix = this.getPlatformPrefix(platform);
305-
const bundlePattern = new RegExp(`^${platformPrefix}-installation-bundle-(.*)\\.zip$`, 'i');
306+
// Note: Current platform prefixes ('vscode', 'windsurf', etc.) don't contain special chars,
307+
// but escaping is kept for future-proofing in case platform names change
308+
const escapedPrefix = escapeRegex(platformPrefix);
309+
const bundlePattern = new RegExp(`^${escapedPrefix}-installation-bundle-(.*)\\.zip$`, 'i');
306310

307311
for (const asset of release.assets) {
308312
const match = asset.name.match(bundlePattern);

src/utils/regexUtils.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Regex utility functions for safe pattern matching and string replacement
3+
* Handles cross-platform path issues (Windows backslashes, special characters)
4+
*/
5+
6+
/**
7+
* Escape special regex characters in a string
8+
* This prevents strings (like Windows paths) from being interpreted as regex patterns
9+
*
10+
* @param str - String to escape
11+
* @returns Escaped string safe for use in RegExp constructor
12+
*
13+
* @example
14+
* ```typescript
15+
* const path = 'C:\\Users\\Test\\file.txt';
16+
* const escaped = escapeRegex(path);
17+
* const regex = new RegExp(escaped); // Safe - won't throw
18+
* ```
19+
*/
20+
export function escapeRegex(str: string): string {
21+
// Escape all special regex characters: . * + ? ^ $ { } ( ) | [ ] \
22+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23+
}
24+
25+
/**
26+
* Create a RegExp from a string, escaping special characters
27+
*
28+
* @param pattern - Pattern string to convert to regex
29+
* @param flags - Optional regex flags (g, i, m, etc.)
30+
* @returns RegExp object
31+
*
32+
* @example
33+
* ```typescript
34+
* const regex = createSafeRegex('path.with.dots', 'g');
35+
* // Matches literal "path.with.dots", not "path" followed by any chars
36+
* ```
37+
*/
38+
export function createSafeRegex(pattern: string, flags?: string): RegExp {
39+
return new RegExp(escapeRegex(pattern), flags);
40+
}
41+
42+
/**
43+
* Replace all occurrences of a literal string in text
44+
* Safer than String.replace() with regex for dynamic patterns
45+
*
46+
* @param text - Text to search in
47+
* @param search - Literal string to find (will be escaped)
48+
* @param replacement - Replacement string (special chars like $ and \ are preserved)
49+
* @returns Text with replacements
50+
*
51+
* @example
52+
* ```typescript
53+
* const template = 'Path: {{PATH}}';
54+
* const windowsPath = 'C:\\Users\\Test';
55+
* const result = replaceAll(template, '{{PATH}}', windowsPath);
56+
* // Result: 'Path: C:\\Users\\Test' (backslashes preserved)
57+
* ```
58+
*/
59+
export function replaceAll(text: string, search: string, replacement: string): string {
60+
const escapedSearch = escapeRegex(search);
61+
const regex = new RegExp(escapedSearch, 'g');
62+
// Use function to prevent interpretation of $ and \ in replacement
63+
return text.replace(regex, () => replacement);
64+
}
65+
66+
/**
67+
* Replace template variables in text with values
68+
* Handles special characters in both keys and values safely
69+
*
70+
* @param text - Template text with placeholders
71+
* @param variables - Object with variable names and values
72+
* @param options - Configuration options
73+
* @returns Text with variables replaced
74+
*
75+
* @example
76+
* ```typescript
77+
* const template = 'Install to: {{PATH}}, version: {{VERSION}}';
78+
* const result = replaceVariables(template, {
79+
* PATH: 'C:\\Users\\Test',
80+
* VERSION: '1.0.0'
81+
* });
82+
* ```
83+
*/
84+
export function replaceVariables(
85+
text: string,
86+
variables: Record<string, string>,
87+
options: {
88+
prefix?: string;
89+
suffix?: string;
90+
} = {}
91+
): string {
92+
const { prefix = '{{', suffix = '}}' } = options;
93+
let result = text;
94+
95+
for (const [key, value] of Object.entries(variables)) {
96+
const placeholder = `${prefix}${key}${suffix}`;
97+
result = replaceAll(result, placeholder, value);
98+
}
99+
100+
return result;
101+
}

test/services/CopilotSyncService.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import * as assert from 'assert';
1010
import * as path from 'path';
11-
import * as os from 'os';
1211
import * as fs from 'fs';
1312
import { CopilotSyncService } from '../../src/services/CopilotSyncService';
1413

@@ -181,6 +180,131 @@ suite('CopilotSyncService', () => {
181180
});
182181
});
183182

183+
suite('Windows Path Regex Handling (Backslash Escaping)', () => {
184+
// These tests verify the fix for: "Invalid regular expression: /\profiles\([^\]+)/: Unterminated character class"
185+
// The issue was that Windows path.sep (\) wasn't properly escaped in regex character classes
186+
187+
test('should handle Windows-style path with backslashes - standard profile', async () => {
188+
// Use path.join to create platform-appropriate paths
189+
const userPath = path.join(tempDir, 'WinTest', 'Users', 'Username', 'AppData', 'Roaming', 'Code', 'User');
190+
const globalStoragePath = path.join(userPath, 'globalStorage', 'amadeusitgroup.prompt-registry');
191+
192+
const winContext = {
193+
globalStorageUri: { fsPath: globalStoragePath },
194+
storageUri: { fsPath: tempDir },
195+
extensionPath: __dirname,
196+
subscriptions: [],
197+
} as any;
198+
199+
const winService = new CopilotSyncService(winContext);
200+
201+
// The key test: should not throw "Invalid regular expression" or "Unterminated character class"
202+
const status = await winService.getStatus();
203+
204+
// Should successfully parse without regex errors
205+
assert.ok(status.copilotDir, 'Should return a valid path');
206+
assert.ok(status.copilotDir.includes('User'), 'Should include User directory');
207+
assert.ok(status.copilotDir.endsWith('prompts'), 'Should end with prompts');
208+
209+
const expectedPath = path.join(userPath, 'prompts');
210+
assert.strictEqual(status.copilotDir, expectedPath, 'Should resolve to User/prompts');
211+
});
212+
213+
test('should handle Windows-style path with backslashes - profile-based', async () => {
214+
// Simulate the exact error case from the screenshot
215+
const userPath = path.join(tempDir, 'WinProfile', 'Users', 'Username', '.vscode', 'extensions', 'dist', 'User');
216+
const profileId = 'security-best-practices';
217+
const globalStoragePath = path.join(userPath, 'profiles', profileId, 'globalStorage', 'amadeusitgroup.prompt-registry');
218+
219+
const winProfileContext = {
220+
globalStorageUri: { fsPath: globalStoragePath },
221+
storageUri: { fsPath: tempDir },
222+
extensionPath: __dirname,
223+
subscriptions: [],
224+
} as any;
225+
226+
const winProfileService = new CopilotSyncService(winProfileContext);
227+
228+
// The key test: should not throw regex errors
229+
const status = await winProfileService.getStatus();
230+
231+
// Should successfully parse the profile path without regex errors
232+
assert.ok(status.copilotDir, 'Should return a valid path');
233+
assert.ok(status.copilotDir.includes('profiles'), 'Should include profiles directory');
234+
assert.ok(status.copilotDir.includes(profileId), 'Should include profile ID');
235+
assert.ok(status.copilotDir.endsWith('prompts'), 'Should end with prompts');
236+
237+
const expectedPath = path.join(userPath, 'profiles', profileId, 'prompts');
238+
assert.strictEqual(status.copilotDir, expectedPath, 'Should resolve to profile prompts directory');
239+
});
240+
241+
test('should handle Windows-style path - custom data directory with profile', async () => {
242+
// Test custom data directory (no User folder) with profile
243+
const customDataDir = path.join(tempDir, 'CustomVSCode', 'Data');
244+
const profileId = 'work-profile';
245+
const globalStoragePath = path.join(customDataDir, 'profiles', profileId, 'globalStorage', 'publisher.extension');
246+
247+
const customContext = {
248+
globalStorageUri: { fsPath: globalStoragePath },
249+
storageUri: { fsPath: tempDir },
250+
extensionPath: __dirname,
251+
subscriptions: [],
252+
} as any;
253+
254+
const customService = new CopilotSyncService(customContext);
255+
256+
// The key test: should not throw regex errors
257+
const status = await customService.getStatus();
258+
259+
// Should handle custom data directory with profiles
260+
assert.ok(status.copilotDir, 'Should return a valid path');
261+
assert.ok(status.copilotDir.includes('profiles'), 'Should include profiles directory');
262+
assert.ok(status.copilotDir.includes(profileId), 'Should include profile ID');
263+
264+
const expectedPath = path.join(customDataDir, 'profiles', profileId, 'prompts');
265+
assert.strictEqual(status.copilotDir, expectedPath, 'Should resolve custom data dir with profile');
266+
});
267+
268+
test('should not throw regex errors with any path separator', async () => {
269+
// This is the core regression test for the bug fix
270+
// The bug was: new RegExp(`[^${path.sep}]`) would create [^\] on Windows
271+
// which is an unterminated character class
272+
273+
const testPath = path.join(tempDir, 'RegexTest', 'Code', 'User', 'profiles', 'test-id', 'globalStorage', 'ext');
274+
275+
const testContext = {
276+
globalStorageUri: { fsPath: testPath },
277+
storageUri: { fsPath: tempDir },
278+
extensionPath: __dirname,
279+
subscriptions: [],
280+
} as any;
281+
282+
const testService = new CopilotSyncService(testContext);
283+
284+
// Should not throw regex errors regardless of platform
285+
try {
286+
const status = await testService.getStatus();
287+
assert.ok(status, 'Should successfully get status');
288+
assert.ok(status.copilotDir, 'Should return a path');
289+
} catch (error: any) {
290+
// The specific errors we're testing for
291+
assert.ok(
292+
!error.message.includes('Invalid regular expression'),
293+
`Should not throw "Invalid regular expression", got: ${error.message}`
294+
);
295+
assert.ok(
296+
!error.message.includes('Unterminated character class'),
297+
`Should not throw "Unterminated character class", got: ${error.message}`
298+
);
299+
assert.ok(
300+
!error.message.includes('SyntaxError'),
301+
`Should not throw SyntaxError from regex, got: ${error.message}`
302+
);
303+
// If it's a different error (like file not found), that's acceptable for this test
304+
}
305+
});
306+
});
307+
184308
suite('getStatus', () => {
185309
test('should return status information', async () => {
186310
const status = await service.getStatus();

0 commit comments

Comments
 (0)