fix(extensions): validate skill folder names before review
This commit is contained in:
parent
1d2e33f5d9
commit
2cc7dc5178
6 changed files with 156 additions and 0 deletions
|
|
@ -35,6 +35,7 @@ import {
|
||||||
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
|
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
|
import { validateSkillFolderName } from './skillValidationUtils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
|
|
@ -305,6 +306,10 @@ export const SkillEditorDialog = ({
|
||||||
if (!folderName.trim()) {
|
if (!folderName.trim()) {
|
||||||
return 'Choose a folder name for this skill.';
|
return 'Choose a folder name for this skill.';
|
||||||
}
|
}
|
||||||
|
const folderNameError = validateSkillFolderName(folderName);
|
||||||
|
if (folderNameError) {
|
||||||
|
return folderNameError;
|
||||||
|
}
|
||||||
if (scope === 'project' && !effectiveProjectPath) {
|
if (scope === 'project' && !effectiveProjectPath) {
|
||||||
return 'Project skills need an active project.';
|
return 'Project skills need an active project.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { FileSearch, FolderOpen, X } from 'lucide-react';
|
||||||
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
import { validateSkillFolderName } from './skillValidationUtils';
|
||||||
|
|
||||||
import type { SkillReviewPreview } from '@shared/types/extensions';
|
import type { SkillReviewPreview } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -126,6 +127,13 @@ export const SkillImportDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReview(): Promise<void> {
|
async function handleReview(): Promise<void> {
|
||||||
|
const folderNameError =
|
||||||
|
folderName.trim().length > 0 ? validateSkillFolderName(folderName) : null;
|
||||||
|
if (folderNameError) {
|
||||||
|
setMutationError(folderNameError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setReviewLoading(true);
|
setReviewLoading(true);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
const MAX_SKILL_FOLDER_NAME_LENGTH = 255;
|
||||||
|
const INVALID_SKILL_FOLDER_NAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/u;
|
||||||
|
|
||||||
|
export function validateSkillFolderName(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return 'Choose a folder name for this skill.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length > MAX_SKILL_FOLDER_NAME_LENGTH) {
|
||||||
|
return `Folder name must be ${MAX_SKILL_FOLDER_NAME_LENGTH} characters or fewer.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '.' || trimmed === '..') {
|
||||||
|
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INVALID_SKILL_FOLDER_NAME_CHARS.test(trimmed)) {
|
||||||
|
return 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -313,4 +313,51 @@ This file uses a freeform layout without generated sections.
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks review locally when the folder name is invalid', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillEditorDialog, {
|
||||||
|
open: true,
|
||||||
|
mode: 'create',
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
projectLabel: 'Project',
|
||||||
|
detail: null,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSaved: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderInput = host.querySelector('#skill-folder') as HTMLInputElement;
|
||||||
|
await act(async () => {
|
||||||
|
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||||
|
setValue?.call(folderInput, 'bad/name');
|
||||||
|
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Review And Create')
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
reviewButton.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storeState.previewSkillUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(host.textContent).toContain(
|
||||||
|
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -378,4 +378,52 @@ describe('SkillImportDialog', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks import review locally when the folder name is invalid', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillImportDialog, {
|
||||||
|
open: true,
|
||||||
|
projectPath: null,
|
||||||
|
projectLabel: null,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onImported: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement;
|
||||||
|
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||||
|
await act(async () => {
|
||||||
|
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||||
|
setValue?.call(sourceInput, '/tmp/source-skill');
|
||||||
|
sourceInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
setValue?.call(folderInput, 'bad/name');
|
||||||
|
folderInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Review And Import')
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
reviewButton.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storeState.previewSkillImport).not.toHaveBeenCalled();
|
||||||
|
expect(host.textContent).toContain(
|
||||||
|
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { validateSkillFolderName } from '../../../../../src/renderer/components/extensions/skills/skillValidationUtils';
|
||||||
|
|
||||||
|
describe('skillValidationUtils', () => {
|
||||||
|
it('accepts normal folder names', () => {
|
||||||
|
expect(validateSkillFolderName('review-helper')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty folder names', () => {
|
||||||
|
expect(validateSkillFolderName(' ')).toBe('Choose a folder name for this skill.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid filesystem characters', () => {
|
||||||
|
expect(validateSkillFolderName('bad/name')).toBe(
|
||||||
|
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects dot segments', () => {
|
||||||
|
expect(validateSkillFolderName('..')).toBe(
|
||||||
|
'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue