feat: milestone 5b — frontend bundle host + VerstakPluginAPI stub

- Bundle contract: window.VerstakPluginRegister(id, {components: {...}})
- PluginBundleHost.svelte: loads bundle via GetPluginAssetContent, mounts components
- VerstakPluginAPI.js: restricted API (capabilities, events, settings, commands — all stub)
- ViewContainer: PluginBundleHost replaces placeholder when frontend bundle exists
- PluginManager: settings panel via PluginBundleHost (removed hardcoded form)
- Backend: GetPluginFrontendInfo, GetPluginAssetContent with path security
- Security: reject absolute paths, path traversal, escape from plugin root
- Error boundary: bundle load/execute/mount errors show fallback, not crash
- Tests: 11 backend tests (asset API), frontend bundle checks in smoke
- Docs: bundle contract, VerstakPluginAPI, security constraints
This commit is contained in:
mirivlad 2026-06-17 17:39:50 +08:00
parent 1dff97a9c0
commit 05ef1449bc
13 changed files with 887 additions and 30 deletions

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
@ -558,6 +559,71 @@ func runContributionsTest(root string) {
}
}
// ── Step 2b: Frontend bundle verification ──
fmt.Printf("\n[frontend bundle verification]\n")
frontendOk := true
// 2b.1 — Check manifest declares frontend.entry
if target.Manifest.Frontend == nil {
fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n")
frontendOk = false
} else {
expectedEntry := "frontend/dist/index.js"
if target.Manifest.Frontend.Entry != expectedEntry {
fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry)
}
// 2b.2 — Resolve entry path and check file exists on disk
resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry)
if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) {
fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry)
frontendOk = false
} else if err != nil {
fmt.Printf(" ❌ frontend bundle stat error: %v\n", err)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry)
// 2b.3 — Read file and check for "VerstakPluginRegister"
data, err := os.ReadFile(resolvedEntry)
if err != nil {
fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err)
frontendOk = false
} else {
content := string(data)
if strings.Contains(content, "VerstakPluginRegister") {
fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n")
} else {
fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n")
frontendOk = false
}
// 2b.4 — Check for expected component names
expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"}
for _, comp := range expectedComponents {
if strings.Contains(content, comp) {
fmt.Printf(" ✅ component registered: %s\n", comp)
} else {
fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp)
frontendOk = false
}
}
}
}
}
if frontendOk {
fmt.Printf(" ✅ frontend bundle checks passed\n")
} else {
fmt.Printf(" ❌ frontend bundle checks FAILED\n")
exitCode = 1
return
}
// ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ──
reg := capability.NewRegistry()
coreCaps := []string{

View File

@ -168,7 +168,7 @@ foreach plugin:
| Тип | Поле manifest | Описание | Frontend host |
|---|---|---|---|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (placeholder — frontend bundle host not implemented) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
@ -239,6 +239,73 @@ foreach plugin:
- ViewContainer показывает "⚠️ Plugin UI failed" fallback
- Error канал: `console.error` + видимый fallback в UI
## Frontend Bundle Contract
### Регистрация компонентов
Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
```javascript
window.VerstakPluginRegister('plugin.id', {
components: {
'ComponentName': {
mount: function(containerEl, props, api) {
// containerEl — div, созданный PluginBundleHost
// api — ограниченный VerstakPluginAPI
containerEl.innerHTML = '<h1>Hello from plugin!</h1>';
},
unmount: function(containerEl) {
// Очистка при смене view
containerEl.innerHTML = '';
}
}
}
});
```
### VerstakPluginAPI
API объект передаётся в `mount()` и содержит только ограниченный набор методов:
| Свойство | Статус | Описание |
|---|---|---|
| `api.pluginId` | ✅ Работает | ID плагина |
| `api.capabilities.has(id)` | 🔧 Stub | Запрос capability registry (planned) |
| `api.events.publish(type, payload)` | 🔧 Stub | Публикация события (planned) |
| `api.events.subscribe(type, handler)` | 🔧 Stub | Подписка на события (planned) |
| `api.settings.read(key)` | 🔧 Stub | Чтение настроек плагина (planned) |
| `api.settings.write(key, value)` | 🔧 Stub | Запись настроек плагина (planned) |
| `api.commands.execute(id, args)` | 🔧 Stub | Выполнение команды (planned) |
### Загрузка бандла
1. `PluginBundleHost` получает pluginId и componentId
2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
5. Находит компонент по componentId и вызывает `mount(container, props, api)`
6. При смене view — вызывает `unmount(container)` для старого компонента
### Безопасность asset path
| Правило | Проверка |
|---|---|
| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
| Нет path traversal | Пути, содержащие `..`, отклоняются |
| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
### manifest frontend config
```json
{
"frontend": {
"entry": "frontend/dist/index.js",
"style": "frontend/style.css"
}
}
```
## Reload
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:

View File

@ -0,0 +1,292 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
// Import the VerstakPluginAPI contract
import './VerstakPluginAPI.js';
export let pluginId = null;
export let componentId = null;
export let viewPluginId = null;
let loadState = 'idle'; // idle | loading | loaded | error
let pluginInfo = null;
let errorText = '';
let mountContainer = null;
let currentPluginId = null;
let currentComponent = null;
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
// React to changes — reload on view change
$: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent);
} else if (!activePluginId) {
cleanup();
loadState = 'idle';
}
onDestroy(() => {
cleanup();
});
function cleanup() {
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
const comp = reg[currentPluginId][currentComponent];
if (comp && comp.unmount && mountContainer) {
try {
comp.unmount(mountContainer);
} catch (e) {
console.error('[PluginBundleHost] unmount error:', e);
}
}
}
if (mountContainer) {
mountContainer.innerHTML = '';
}
currentPluginId = null;
currentComponent = null;
}
async function loadAndMount(pId, compId) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && loadState === 'loaded') {
return;
}
// Cleanup previous
cleanup();
loadState = 'loading';
errorText = '';
currentPluginId = pId;
currentComponent = compId;
try {
// Get plugin frontend info
const info = await App.GetPluginFrontendInfo(pId);
pluginInfo = info;
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? 'Plugin has no frontend bundle'
: 'Plugin not found';
return;
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
// Load the bundle JS content via backend API
const [content, err] = await App.GetPluginAssetContent(pId, info.entry);
if (err || !content) {
loadState = 'error';
errorText = 'Failed to load bundle: ' + (err || 'empty content');
return;
}
// Execute bundle via Function constructor (safe: no access to outer scope)
// This is equivalent to eval but more explicit
try {
const fn = new Function(content);
fn();
} catch (e) {
loadState = 'error';
errorText = 'Bundle execution error: ' + e.message;
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
return;
}
}
// Find the component
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = 'Component "' + compId + '" not found in bundle. Available: '
+ (Object.keys(components).join(', ') || 'none');
return;
}
// Create API
const api = window.VerstakPluginAPI(pId);
// Mount component
if (!mountContainer) {
// Container must exist in DOM — wait for next tick
await new Promise(r => requestAnimationFrame(r));
}
if (mountContainer) {
try {
comp.mount(mountContainer, { componentId: compId }, api);
loadState = 'loaded';
errorText = '';
} catch (e) {
loadState = 'error';
errorText = 'Component mount error: ' + e.message;
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = 'Mount container not available';
}
} catch (e) {
loadState = 'error';
errorText = 'Unexpected error: ' + (e.message || e);
console.error('[PluginBundleHost] error:', e);
}
}
function getComponentList() {
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (!reg || !currentPluginId || !reg[currentPluginId]) return [];
return Object.keys(reg[currentPluginId]);
}
</script>
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>Select a plugin view from the sidebar</p>
</div>
{:else if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<div class="error-icon">⚠️</div>
<p class="error-title">Plugin View Error</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
<p class="error-message">{errorText || 'Unknown error'}</p>
{#if pluginInfo}
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
{/if}
</div>
</div>
{:else if loadState === 'loaded'}
<div
class="plugin-mount-container"
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
></div>
{/if}
</div>
<style>
.plugin-bundle-host {
width: 100%;
height: 100%;
min-height: 200px;
display: flex;
flex-direction: column;
}
.host-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.host-state.idle {
color: #555;
}
.host-state.loading {
color: #a0a0b8;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #333;
border-top-color: #4ecca3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.host-state.error {
color: #e94560;
}
.error-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.error-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 1rem;
}
.error-details {
font-size: 0.85rem;
color: #a0a0b8;
max-width: 400px;
text-align: left;
background: #16213e;
padding: 1rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details p {
margin: 0.3rem 0;
}
.error-details strong {
color: #e0e0f0;
}
.error-message {
color: #e94560;
font-family: monospace;
font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
}
.error-meta {
font-size: 0.75rem;
color: #666;
margin-top: 0.3rem !important;
}
.plugin-mount-container {
flex: 1;
overflow: auto;
position: relative;
}
</style>

View File

@ -0,0 +1,68 @@
// VerstakPluginAPI is the restricted API passed to plugin frontend bundles.
// Plugins do NOT get direct access to Wails bridge — only what's exposed here.
// All methods are stubs or limited implementations.
(function() {
// Store registered components per plugin
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
// Original register function
const origRegister = window.VerstakPluginRegister;
if (origRegister) {
// Already defined — don't override
return;
}
window.VerstakPluginRegister = function(pluginId, bundle) {
if (!pluginId || !bundle || !bundle.components) {
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
return;
}
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
};
// Create the restricted API object for a plugin host context
window.VerstakPluginAPI = function(pluginId) {
return {
pluginId: pluginId,
capabilities: {
has: function(capId) {
// planned: query backend cap registry
console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub');
return false;
}
},
events: {
publish: function(type, payload) {
console.log('[plugin:' + pluginId + '] event publish:', type, payload);
// planned: actual event bus bridge
},
subscribe: function(type, handler) {
console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)');
// planned: actual event bus bridge
}
},
settings: {
read: function(key) {
console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub');
return null;
},
write: function(key, value) {
console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub');
// planned: backend storage namespace
}
},
commands: {
execute: function(cmdId, args) {
console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub');
// planned: command execution
}
}
};
};
})();

View File

@ -1,7 +1,8 @@
<script>
import PluginCard from './PluginCard.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings } from '../../../wailsjs/go/api/App';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo } from '../../../wailsjs/go/api/App';
let plugins = [];
let capabilities = [];
@ -15,6 +16,7 @@
let settingsData = {};
let settingsPluginId = '';
let settingsError = null;
let settingsPluginInfo = null;
let lastOpenedKey = '';
export let activeSettingsPluginId = '';
@ -28,12 +30,17 @@
}
}
function openSettingsFromProps(pluginId, panelId) {
async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) {
settingsPanel = panel;
settingsPluginId = pluginId;
settingsError = null;
// Get plugin frontend info
try {
const info = await GetPluginFrontendInfo(pluginId);
settingsPluginInfo = info;
} catch { settingsPluginInfo = null; }
ReadPluginSettings(pluginId).then(data => {
settingsData = data || {};
}).catch(() => { settingsData = {}; });
@ -245,31 +252,19 @@
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
<div class="modal-header">
<h3>{settingsPanel.item.title}</h3>
<h3>{settingsPanel.title}</h3>
<button class="modal-close" on:click={closeSettings} type="button"></button>
</div>
<div class="modal-body">
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
<p class="settings-hint">Component: <code>{settingsPanel.item.component}</code></p>
{#if settingsPanel.item.id === 'verstak.platform-test.settings'}
<div class="settings-form">
<h4>Test Settings</h4>
<div class="form-row">
<label for="test-name">Test Name</label>
<input id="test-name" type="text" bind:value={settingsData.testName} placeholder="Enter test name" />
</div>
<div class="form-row">
<label for="test-interval">Test Interval (seconds)</label>
<input id="test-interval" type="number" bind:value={settingsData.testInterval} min="1" max="300" />
</div>
<div class="form-row">
<label><input type="checkbox" bind:checked={settingsData.autoRun} /> Auto-run on startup</label>
</div>
<button class="btn-save" on:click={() => saveSettings()} type="button">Save Settings</button>
</div>
{#if settingsPluginInfo && settingsPluginInfo.entry}
<PluginBundleHost
pluginId={settingsPluginId}
componentId={settingsPanel.component || settingsPanel.id}
/>
{:else}
<p class="placeholder">Settings component: {settingsPanel.item.component}</p>
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
<p class="placeholder">Settings panel frontend bundle not available</p>
{/if}
</div>
</div>

View File

@ -1,4 +1,5 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
@ -27,11 +28,18 @@
? plugins.find(p => p.manifest?.id === currentView.pluginId)
: null;
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
$: hasFrontend = currentPlugin?.manifest?.frontend?.entry != null;
$: hostPluginId = currentView?.pluginId || activeViewPluginId;
$: hostComponentId = currentView?.component || null;
// Reset render error when view changes
$: if (activeView) {
renderError = null;
}
function onHostError(e) {
renderError = e.detail?.message || 'Plugin view error';
}
</script>
{#key `${activeViewPluginId}:${activeView}`}
@ -51,17 +59,27 @@
<div class="view-header">
<span class="view-icon">{currentView.icon || '📦'}</span>
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
{:else}
<span class="no-frontend-badge">no frontend bundle</span>
{/if}
</div>
<div class="view-content">
<div class="plugin-view-host" data-view-id={currentView.id} data-component={currentView.component}>
{#if hasFrontend}
<PluginBundleHost
pluginId={hostPluginId}
componentId={hostComponentId}
/>
{:else}
<div class="placeholder">
<p class="placeholder-label">Plugin View Host</p>
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
<p class="placeholder-badge">frontend bundle host not implemented yet</p>
<p class="placeholder-badge">frontend bundle not available</p>
</div>
</div>
{/if}
</div>
</div>
</div>
@ -109,14 +127,30 @@
flex: 1;
}
.view-icon { font-size: 1.3rem; }
.frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.no-frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(233, 69, 96, 0.1);
color: #e94560;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.view-content {
flex: 1;
overflow: auto;
}
.plugin-view-host {
width: 100%;
min-height: 200px;
}
.placeholder {
color: #666;
font-style: italic;

View File

@ -27,6 +27,10 @@ export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultPluginState():Promise<Record<string, any>>;

View File

@ -46,6 +46,14 @@ export function GetPermissions() {
return window['go']['api']['App']['GetPermissions']();
}
export function GetPluginAssetContent(arg1, arg2) {
return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2);
}
export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}

0
frontend/wailsjs/runtime/package.json Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file → Executable file
View File

0
frontend/wailsjs/runtime/runtime.js Normal file → Executable file
View File

View File

@ -711,3 +711,69 @@ func (a *App) SelectVaultForOpen() string {
}
return selected
}
// ─── Plugin Frontend Asset API ───────────────────────────
// GetPluginFrontendInfo returns frontend metadata for a plugin.
// Returns empty map if plugin has no frontend bundle or is not found.
func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
for _, p := range a.plugins {
if p.Manifest.ID != pluginID {
continue
}
if p.Manifest.Frontend == nil {
return map[string]interface{}{"status": "no-frontend"}
}
return map[string]interface{}{
"pluginId": p.Manifest.ID,
"name": p.Manifest.Name,
"icon": p.Manifest.Icon,
"version": p.Manifest.Version,
"entry": p.Manifest.Frontend.Entry,
"style": p.Manifest.Frontend.Style,
"rootPath": p.RootPath,
}
}
return map[string]interface{}{"status": "not-found"}
}
// GetPluginAssetContent reads a frontend asset file from a plugin directory.
// Security: validates that the assetPath is relative and does not escape the plugin root.
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
// Validate asset path — reject absolute paths and path traversal
if strings.HasPrefix(assetPath, "/") || strings.HasPrefix(assetPath, "\\") {
return "", "absolute paths not allowed"
}
if strings.Contains(assetPath, "..") {
return "", "path traversal not allowed"
}
// Find the plugin
var pluginRoot string
found := false
for _, p := range a.plugins {
if p.Manifest.ID == pluginID && p.Manifest.Frontend != nil {
pluginRoot = p.RootPath
found = true
break
}
}
if !found {
return "", "plugin not found or has no frontend"
}
// Resolve path relative to plugin root
fullPath := filepath.Join(pluginRoot, assetPath)
// Verify we haven't escaped plugin root
absRoot, _ := filepath.Abs(pluginRoot)
absPath, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absPath, absRoot+string(filepath.Separator)) && absPath != absRoot {
return "", "path escapes plugin root"
}
data, err := os.ReadFile(absPath)
if err != nil {
return "", fmt.Sprintf("failed to read asset: %v", err)
}
return string(data), ""
}

257
internal/api/app_test.go Normal file
View File

@ -0,0 +1,257 @@
package api
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
// newTestApp creates an App with a mocked plugin list for testing.
func newTestApp(tmpRoot string) *App {
return &App{
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "test.plugin",
Name: "Test Plugin",
Version: "1.0.0",
Icon: "🧪",
Frontend: &plugin.FrontendConfig{
Entry: "frontend/dist/index.js",
Style: "frontend/style.css",
},
Provides: []string{"test/cap/v1"},
Permissions: []string{"test.perm"},
},
RootPath: tmpRoot,
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "no-fe.plugin",
Name: "No Frontend Plugin",
Provides: []string{"test/nofe/v1"},
Permissions: []string{"test.perm"},
},
RootPath: "/tmp/no-fe-plugin",
Status: plugin.StatusLoaded,
Enabled: true,
},
},
}
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("test.plugin")
if info["status"] != nil {
t.Errorf("unexpected status key: expected no status, got %v", info["status"])
}
if info["pluginId"] != "test.plugin" {
t.Errorf("pluginId: expected %q, got %v", "test.plugin", info["pluginId"])
}
if info["name"] != "Test Plugin" {
t.Errorf("name: expected %q, got %v", "Test Plugin", info["name"])
}
if info["icon"] != "🧪" {
t.Errorf("icon: expected %q, got %v", "🧪", info["icon"])
}
if info["version"] != "1.0.0" {
t.Errorf("version: expected %q, got %v", "1.0.0", info["version"])
}
if info["entry"] != "frontend/dist/index.js" {
t.Errorf("entry: expected %q, got %v", "frontend/dist/index.js", info["entry"])
}
if info["style"] != "frontend/style.css" {
t.Errorf("style: expected %q, got %v", "frontend/style.css", info["style"])
}
if info["rootPath"] != "/tmp/test-plugin" {
t.Errorf("rootPath: expected %q, got %v", "/tmp/test-plugin", info["rootPath"])
}
}
// TestGetPluginFrontendInfo_PluginWithoutFrontend verifies that
// GetPluginFrontendInfo returns {"status": "no-frontend"} for a plugin
// that has no FrontendConfig.
func TestGetPluginFrontendInfo_PluginWithoutFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("no-fe.plugin")
if info["status"] != "no-frontend" {
t.Errorf("expected status %q, got %v", "no-frontend", info["status"])
}
}
// TestGetPluginFrontendInfo_UnknownPlugin verifies that
// GetPluginFrontendInfo returns {"status": "not-found"} for a plugin ID
// that does not exist.
func TestGetPluginFrontendInfo_UnknownPlugin(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("nonexistent.plugin")
if info["status"] != "not-found" {
t.Errorf("expected status %q, got %v", "not-found", info["status"])
}
}
// TestGetPluginAssetContent_ExistingFile verifies that GetPluginAssetContent
// can read an existing frontend file from a plugin directory.
func TestGetPluginAssetContent_ExistingFile(t *testing.T) {
tmpDir := t.TempDir()
// Create a test frontend file
frontendDir := filepath.Join(tmpDir, "frontend", "dist")
if err := os.MkdirAll(frontendDir, 0755); err != nil {
t.Fatal(err)
}
content := "console.log('test');\n"
if err := os.WriteFile(filepath.Join(frontendDir, "index.js"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
app := newTestApp(tmpDir)
got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/index.js")
if errStr != "" {
t.Errorf("unexpected error: %s", errStr)
}
if got != content {
t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
}
}
// TestGetPluginAssetContent_ExistingStyleFile verifies reading a style file.
func TestGetPluginAssetContent_ExistingStyleFile(t *testing.T) {
tmpDir := t.TempDir()
frontendDir := filepath.Join(tmpDir, "frontend")
if err := os.MkdirAll(frontendDir, 0755); err != nil {
t.Fatal(err)
}
content := "body { color: red; }\n"
if err := os.WriteFile(filepath.Join(frontendDir, "style.css"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
app := newTestApp(tmpDir)
got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/style.css")
if errStr != "" {
t.Errorf("unexpected error: %s", errStr)
}
if got != content {
t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
}
}
// TestGetPluginAssetContent_AbsolutePathRejected verifies that asset paths
// starting with "/" are rejected.
func TestGetPluginAssetContent_AbsolutePathRejected(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("test.plugin", "/etc/passwd")
if errStr == "" {
t.Error("expected error for absolute path, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "absolute") {
t.Errorf("error should mention 'absolute', got: %s", errStr)
}
// Also test Windows-style absolute path
_, errStr = app.GetPluginAssetContent("test.plugin", "\\etc\\passwd")
if errStr == "" {
t.Error("expected error for windows-style absolute path, got empty")
}
}
// TestGetPluginAssetContent_PathTraversalRejected verifies that asset paths
// containing ".." are rejected.
func TestGetPluginAssetContent_PathTraversalRejected(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("test.plugin", "../../etc/passwd")
if errStr == "" {
t.Error("expected error for path traversal, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "traversal") {
t.Errorf("error should mention 'traversal', got: %s", errStr)
}
}
// TestGetPluginAssetContent_PathEscapeRejected verifies that paths that
// resolve outside the plugin root directory are rejected after Join.
// This tests the absRoot-prefix check in GetPluginAssetContent.
func TestGetPluginAssetContent_PathEscapeRejected(t *testing.T) {
tmpDir := t.TempDir()
// Do NOT create the traversed-to file — the security check happens
// before os.ReadFile, so the file should not matter.
app := newTestApp(tmpDir)
// Use a relative path with ".." that would escape the plugin root
// The code checks strings.Contains(assetPath, "..") first, so this
// would be caught at the traversal check. But let's also test a case
// where ".." is NOT in the path but Join resolves outside root.
// For instance: symlink-based escape (not testable easily) or
// Join "/tmp/root" + "foo/../../../etc" — but ".." is in there.
//
// Instead, test that the absRoot prefix check works: create a path
// that after cleaning technically starts differently but doesn't use "..".
// This is hard to reproduce without symlinks. The ".." check catches
// common cases. Let's just ensure the ".." check is solid:
_, errStr := app.GetPluginAssetContent("test.plugin", "frontend/../../etc/passwd")
if errStr == "" {
t.Error("expected error for path traversal via nested '..', got empty")
}
}
// TestGetPluginAssetContent_PluginNotFound verifies that GetPluginAssetContent
// returns an error for a nonexistent plugin ID.
func TestGetPluginAssetContent_PluginNotFound(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("nonexistent.plugin", "frontend/dist/index.js")
if errStr == "" {
t.Error("expected error for nonexistent plugin, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "not found") &&
!strings.Contains(strings.ToLower(errStr), "no frontend") {
t.Errorf("error should mention 'not found' or 'no frontend', got: %s", errStr)
}
}
// TestGetPluginAssetContent_NoFrontend verifies that GetPluginAssetContent
// returns an error for a plugin that exists but has no frontend config.
func TestGetPluginAssetContent_NoFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("no-fe.plugin", "frontend/dist/index.js")
if errStr == "" {
t.Error("expected error for plugin without frontend, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "no frontend") {
t.Errorf("error should mention 'no frontend', got: %s", errStr)
}
}
// TestGetPluginAssetContent_NonexistentFile verifies that GetPluginAssetContent
// returns an error when the asset file does not exist on disk.
func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
tmpDir := t.TempDir()
app := newTestApp(tmpDir)
_, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/missing.js")
if errStr == "" {
t.Error("expected error for nonexistent file, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "failed to read") {
t.Errorf("error should mention 'failed to read', got: %s", errStr)
}
}