This commit is contained in:
aleksey-hoffman committed 2026-05-17 18:45:15 +03:00
1 parent f2bef61195
commit 8d8bc8517d
9 files changed
+284 -12

No files matched your search

+6 -3
View File
@@ -590,16 +590,19 @@
"properties": {
"id": {
"type": "string",
"minLength": 1
"minLength": 1,
"pattern": "\\S"
},
"label": {
"type": "string",
"minLength": 1
"minLength": 1,
"pattern": "\\S"
},
"path": {
"type": "string",
"description": "Relative path to the icon theme JSON file",
"minLength": 1
"minLength": 1,
"pattern": "^(?!\\s*$)(?!\\s*(?:[A-Za-z]:|[\\\\/]))(?!\\s*\\.\\.(?:[\\\\/]|\\s*$))(?!.*[\\\\/]\\.\\.(?:[\\\\/]|\\s*$))(?=.*[^.\\\\/\\s]).*$"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sigma-file-manager/api",
"version": "1.8.0",
"version": "1.8.1",
"description": "Type definitions and manifest schema for Sigma File Manager extensions",
"license": "GPL-3.0-or-later",
"repository": {
@@ -5,7 +5,8 @@
import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
import { effectScope, ref } from 'vue';
import { effectScope, nextTick, ref } from 'vue';
import type { LoadedIconThemeDefinition } from '@/types/icon-theme';
import { BUILTIN_NAVIGATOR_ICON_THEME_IDS } from '@/types/icon-theme';
const {
@@ -148,8 +149,91 @@ describe('useNavigatorItemIcon', () => {
folderScope.stop();
fileScope.stop();
});
it('ignores stale extension theme loads after a newer load starts', async () => {
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'extension:acme.icons:shared';
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'extension:acme.icons:shared';
const staleThemeLoad = createDeferredThemeLoad();
const currentThemeLoad = createDeferredThemeLoad();
loadInstalledIconThemeMock
.mockImplementationOnce(() => staleThemeLoad.promise)
.mockImplementationOnce(() => currentThemeLoad.promise);
const { useNavigatorItemIcon } = await import('@/composables/use-navigator-item-icon');
const iconScope = effectScope();
const iconResolutionEnabled = ref(true);
const iconResult = iconScope.run(() => useNavigatorItemIcon({
path: ref('C:/Projects/app.ts'),
name: ref('app.ts'),
isDir: ref(false),
extension: ref('ts'),
size: ref(32),
enabled: iconResolutionEnabled,
}));
await waitForLoadCount(1);
iconResolutionEnabled.value = false;
await nextTick();
iconResolutionEnabled.value = true;
await nextTick();
await waitForLoadCount(2);
currentThemeLoad.resolveTheme(createLoadedTheme('current'));
await waitForIcon(() => iconResult?.iconSrc.value, 'current:file');
staleThemeLoad.resolveTheme(createLoadedTheme('stale'));
await Promise.resolve();
await new Promise(resolveTimer => setTimeout(resolveTimer, 0));
expect(iconResult?.iconSrc.value).toBe('current:file');
iconScope.stop();
});
});
function createLoadedTheme(iconPrefix: string): LoadedIconThemeDefinition {
return {
iconDefinitions: {
file: {
src: `${iconPrefix}:file`,
},
folder: {
src: `${iconPrefix}:folder`,
},
},
file: 'file',
folder: 'folder',
};
}
function createDeferredThemeLoad() {
let resolveTheme: (theme: LoadedIconThemeDefinition) => void = () => {};
const promise = new Promise<LoadedIconThemeDefinition>((resolvePromise) => {
resolveTheme = resolvePromise;
});
return {
promise,
resolveTheme,
};
}
async function waitForLoadCount(expectedCallCount: number) {
for (let attemptIndex = 0; attemptIndex < 20; attemptIndex += 1) {
if (loadInstalledIconThemeMock.mock.calls.length >= expectedCallCount) {
return;
}
await Promise.resolve();
await new Promise(resolveTimer => setTimeout(resolveTimer, 0));
}
expect(loadInstalledIconThemeMock.mock.calls.length).toBeGreaterThanOrEqual(expectedCallCount);
}
async function waitForIcon(readValue: () => string | null | undefined, expectedValue: string) {
for (let attemptIndex = 0; attemptIndex < 20; attemptIndex += 1) {
if (readValue() === expectedValue) {
+8 -2
View File
@@ -93,7 +93,13 @@ export function useNavigatorItemIcon(params: NavigatorItemIconParams) {
enabled: useSystemIcons,
});
watchEffect(async () => {
watchEffect(async (onCleanup) => {
let isCurrentLoad = true;
onCleanup(() => {
isCurrentLoad = false;
});
if (parsedTheme.value?.kind !== 'extension') {
loadedExtensionTheme.value = null;
return;
@@ -105,7 +111,7 @@ export function useNavigatorItemIcon(params: NavigatorItemIconParams) {
requestThemeId,
);
if (selectedIconTheme.value === requestThemeId) {
if (isCurrentLoad && selectedIconTheme.value === requestThemeId) {
loadedExtensionTheme.value = loadedTheme;
}
});
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
type JsonObject = Record<string, unknown>;
const manifestSchema = JSON.parse(
readFileSync(
join(process.cwd(), 'packages/api/manifest.schema.json'),
'utf8',
),
) as JsonObject;
describe('manifest JSON schema icon theme validation', () => {
it('rejects blank icon theme ids and labels', () => {
expect(matchesIconThemePropertySchema('id', ' ')).toBe(false);
expect(matchesIconThemePropertySchema('label', '\t')).toBe(false);
expect(matchesIconThemePropertySchema('id', 'ocean')).toBe(true);
expect(matchesIconThemePropertySchema('label', 'Ocean')).toBe(true);
});
it('rejects unsafe icon theme paths', () => {
const unsafePaths = [
'',
' ',
'.',
'./',
'../theme.json',
' ../theme.json',
'themes/../theme.json',
'/theme.json',
'\\theme.json',
'C:/theme.json',
];
for (const unsafePath of unsafePaths) {
expect(matchesIconThemePropertySchema('path', unsafePath)).toBe(false);
}
});
it('accepts safe icon theme paths', () => {
const safePaths = [
'themes/ocean-icon-theme.json',
'./themes/ocean-icon-theme.json',
'.themes/ocean-icon-theme.json',
'themes\\ocean-icon-theme.json',
];
for (const safePath of safePaths) {
expect(matchesIconThemePropertySchema('path', safePath)).toBe(true);
}
});
});
function matchesIconThemePropertySchema(propertyName: string, value: string): boolean {
const propertySchema = getIconThemePropertySchema(propertyName);
const minLength = propertySchema.minLength;
if (typeof minLength === 'number' && value.length < minLength) {
return false;
}
const pattern = propertySchema.pattern;
if (typeof pattern === 'string' && !new RegExp(pattern).test(value)) {
return false;
}
return true;
}
function getIconThemePropertySchema(propertyName: string): JsonObject {
const manifestProperties = asJsonObject(manifestSchema.properties);
const contributes = asJsonObject(manifestProperties.contributes);
const contributesProperties = asJsonObject(contributes.properties);
const iconThemes = asJsonObject(contributesProperties.iconThemes);
const iconThemeItem = asJsonObject(iconThemes.items);
const iconThemeProperties = asJsonObject(iconThemeItem.properties);
return asJsonObject(iconThemeProperties[propertyName]);
}
function asJsonObject(value: unknown): JsonObject {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('Expected a JSON object');
}
return value as JsonObject;
}
@@ -238,6 +238,23 @@ describe('manifest binary validation', () => {
expect(() => assertValidManifestData(manifest)).toThrow('Invalid manifest: contributes.iconThemes are invalid');
});
it('rejects icon theme contributions with trimmed unsafe paths', () => {
const manifest = {
...createManifest(),
contributes: {
iconThemes: [
{
id: 'ocean',
label: 'Ocean',
path: ' ../themes/ocean-icon-theme.json',
},
],
},
};
expect(() => assertValidManifestData(manifest)).toThrow('Invalid manifest: contributes.iconThemes are invalid');
});
it('rejects icon-theme-only api extensions without a main entry when they include runtime contributions', () => {
const { main: omittedMain, ...manifest } = {
...createManifest(),
+4 -2
View File
@@ -69,11 +69,13 @@ function isValidArchList(value: unknown): boolean {
}
function isSafeRelativePath(value: string): boolean {
if (!value.trim() || /^[a-zA-Z]:/.test(value) || value.startsWith('/') || value.startsWith('\\')) {
const normalizedValue = value.trim();
if (!normalizedValue || /^[a-zA-Z]:/.test(normalizedValue) || normalizedValue.startsWith('/') || normalizedValue.startsWith('\\')) {
return false;
}
const pathSegments = value
const pathSegments = normalizedValue
.split(/[\\/]+/)
.filter(segment => segment.length > 0 && segment !== '.');
@@ -78,6 +78,57 @@ describe('icon theme resolver', () => {
expect(icon).toBe('file.svg');
});
it('falls back to the extension icon when a file name definition is missing', () => {
const theme = createTheme();
theme.fileNames = {
...theme.fileNames,
'app.ts': 'missingIcon',
};
const icon = resolveLoadedIconThemeIcon(theme, {
name: 'app.ts',
parentName: 'src',
extension: 'ts',
isDirectory: false,
});
expect(icon).toBe('ts.svg');
});
it('falls back to the generic file icon when an extension definition is missing', () => {
const theme = createTheme();
theme.fileExtensions = {
...theme.fileExtensions,
zip: 'missingIcon',
};
const icon = resolveLoadedIconThemeIcon(theme, {
name: 'archive.zip',
parentName: 'downloads',
extension: 'zip',
isDirectory: false,
});
expect(icon).toBe('file.svg');
});
it('falls back to the generic folder icon when a folder name definition is missing', () => {
const theme = createTheme();
theme.folderNames = {
...theme.folderNames,
cache: 'missingIcon',
};
const icon = resolveLoadedIconThemeIcon(theme, {
name: 'cache',
parentName: 'workspace',
extension: null,
isDirectory: true,
});
expect(icon).toBe('folder.svg');
});
it('produces extension candidates from longest to shortest', () => {
expect(getFileExtensionCandidates('archive.tar.gz')).toEqual(['tar.gz', 'gz']);
});
+19 -3
View File
@@ -117,7 +117,15 @@ export function resolveLoadedIconThemeIcon(
normalizedParentName,
);
return getDefinitionSrc(theme, folderNameMatch ?? theme.folder ?? theme.folderExpanded);
if (folderNameMatch) {
const folderNameIconSrc = getDefinitionSrc(theme, folderNameMatch);
if (folderNameIconSrc) {
return folderNameIconSrc;
}
}
return getDefinitionSrc(theme, theme.folder ?? theme.folderExpanded);
}
const fileNameMatch = resolveAssociationRecord(
@@ -127,7 +135,11 @@ export function resolveLoadedIconThemeIcon(
);
if (fileNameMatch) {
return getDefinitionSrc(theme, fileNameMatch);
const fileNameIconSrc = getDefinitionSrc(theme, fileNameMatch);
if (fileNameIconSrc) {
return fileNameIconSrc;
}
}
const normalizedExtension = target.extension?.trim().toLowerCase() || null;
@@ -145,7 +157,11 @@ export function resolveLoadedIconThemeIcon(
);
if (extensionMatch) {
return getDefinitionSrc(theme, extensionMatch);
const extensionIconSrc = getDefinitionSrc(theme, extensionMatch);
if (extensionIconSrc) {
return extensionIconSrc;
}
}
}