mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
Refine: #421
This commit is contained in:
1 parent
f2bef61195
commit
8d8bc8517d
9 files changed
+284
-12
No files matched your search
@@ -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,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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user