feat: load declared plugin locale catalogs
This commit is contained in:
parent
15f1f413d0
commit
8b1151e03a
|
|
@ -2080,18 +2080,81 @@ func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
|
|||
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,
|
||||
"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,
|
||||
"localization": p.Manifest.Localization,
|
||||
"rootPath": p.RootPath,
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{"status": "not-found"}
|
||||
}
|
||||
|
||||
// GetPluginLocalization reads a locale catalog declared by the plugin manifest.
|
||||
func (a *App) GetPluginLocalization(pluginID, locale string) (map[string]string, string) {
|
||||
var selected *plugin.Plugin
|
||||
for i := range a.plugins {
|
||||
if a.plugins[i].Manifest.ID == pluginID {
|
||||
selected = &a.plugins[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, "plugin not found"
|
||||
}
|
||||
localization := selected.Manifest.Localization
|
||||
if localization == nil {
|
||||
return nil, "plugin does not declare localization"
|
||||
}
|
||||
catalogPath, ok := localization.Locales[locale]
|
||||
if !ok {
|
||||
return nil, fmt.Sprintf("locale %q is not declared", locale)
|
||||
}
|
||||
if err := plugin.ValidateLocalizationPath(catalogPath); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
|
||||
absRoot, err := filepath.Abs(selected.RootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("resolve plugin root: %v", err)
|
||||
}
|
||||
absPath, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(catalogPath)))
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("resolve catalog path: %v", err)
|
||||
}
|
||||
if !pathInsideRoot(absRoot, absPath) {
|
||||
return nil, "catalog path escapes plugin root"
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("failed to resolve catalog: %v", err)
|
||||
}
|
||||
if !pathInsideRoot(absRoot, resolvedPath) {
|
||||
return nil, "catalog path escapes plugin root"
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Sprintf("failed to read catalog: %v", err)
|
||||
}
|
||||
catalog := map[string]string{}
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
return nil, fmt.Sprintf("failed to parse catalog: %v", err)
|
||||
}
|
||||
return catalog, ""
|
||||
}
|
||||
|
||||
func pathInsideRoot(root, candidate string) bool {
|
||||
relative, err := filepath.Rel(root, candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -343,6 +343,65 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPluginLocalizationReadsDeclaredCatalog(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "locales", "ru.json"), []byte(`{"greeting":"Привет","count":"Всего: {count}"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newTestApp(root)
|
||||
app.plugins[0].Manifest.Localization = &plugin.LocalizationConfig{
|
||||
DefaultLocale: "en",
|
||||
Locales: map[string]string{
|
||||
"en": "locales/en.json",
|
||||
"ru": "locales/ru.json",
|
||||
},
|
||||
}
|
||||
|
||||
catalog, errStr := app.GetPluginLocalization("test.plugin", "ru")
|
||||
if errStr != "" {
|
||||
t.Fatalf("GetPluginLocalization: %s", errStr)
|
||||
}
|
||||
if catalog["greeting"] != "Привет" || catalog["count"] != "Всего: {count}" {
|
||||
t.Fatalf("catalog = %#v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginLocalizationRejectsInvalidCatalogs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "locales", "bad.json"), []byte(`{"valid":"text","invalid":42}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := newTestApp(root)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *plugin.LocalizationConfig
|
||||
locale string
|
||||
want string
|
||||
}{
|
||||
{"missing declaration", nil, "ru", "does not declare"},
|
||||
{"unknown locale", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/en.json"}}, "ru", "not declared"},
|
||||
{"traversal", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../outside.json"}}, "en", "traversal"},
|
||||
{"backslash", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\bad.json`}}, "en", "backslash"},
|
||||
{"non string value", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/bad.json"}}, "en", "parse"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app.plugins[0].Manifest.Localization = tc.config
|
||||
_, errStr := app.GetPluginLocalization("test.plugin", tc.locale)
|
||||
if !strings.Contains(errStr, tc.want) {
|
||||
t.Fatalf("error = %q, want substring %q", errStr, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,28 +7,36 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest represents a Verstak plugin.json manifest.
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Provides []string `json:"provides"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Frontend *FrontendConfig `json:"frontend,omitempty"`
|
||||
Backend *BackendConfig `json:"backend,omitempty"`
|
||||
Migrations *MigrationConfig `json:"migrations,omitempty"`
|
||||
Contributes *Contributions `json:"contributes,omitempty"`
|
||||
Sync *SyncConfig `json:"sync,omitempty"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Localization *LocalizationConfig `json:"localization,omitempty"`
|
||||
Provides []string `json:"provides"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Frontend *FrontendConfig `json:"frontend,omitempty"`
|
||||
Backend *BackendConfig `json:"backend,omitempty"`
|
||||
Migrations *MigrationConfig `json:"migrations,omitempty"`
|
||||
Contributes *Contributions `json:"contributes,omitempty"`
|
||||
Sync *SyncConfig `json:"sync,omitempty"`
|
||||
}
|
||||
|
||||
// LocalizationConfig declares plugin-owned UI message catalogs.
|
||||
type LocalizationConfig struct {
|
||||
DefaultLocale string `json:"defaultLocale"`
|
||||
Locales map[string]string `json:"locales"`
|
||||
}
|
||||
|
||||
// FrontendConfig describes the plugin's frontend bundle.
|
||||
|
|
@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
|
|||
if len(m.Permissions) == 0 {
|
||||
errs.add("permissions must have at least one permission")
|
||||
}
|
||||
if m.Localization != nil {
|
||||
if !isValidLocaleTag(m.Localization.DefaultLocale) {
|
||||
errs.add("localization.defaultLocale %q is not a valid lower-case locale", m.Localization.DefaultLocale)
|
||||
}
|
||||
if len(m.Localization.Locales) == 0 {
|
||||
errs.add("localization.locales must contain at least one catalog")
|
||||
}
|
||||
if _, ok := m.Localization.Locales[m.Localization.DefaultLocale]; !ok {
|
||||
errs.add("localization.defaultLocale %q must have a declared catalog", m.Localization.DefaultLocale)
|
||||
}
|
||||
for locale, catalogPath := range m.Localization.Locales {
|
||||
if !isValidLocaleTag(locale) {
|
||||
errs.add("localization locale %q is not a valid lower-case locale", locale)
|
||||
}
|
||||
if err := ValidateLocalizationPath(catalogPath); err != nil {
|
||||
errs.add("localization locale %q: %v", locale, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.Contributes != nil {
|
||||
for i, provider := range m.Contributes.OpenProviders {
|
||||
if provider.ID == "" {
|
||||
|
|
@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
|
|||
return errs.errors
|
||||
}
|
||||
|
||||
var localeTagPattern = regexp.MustCompile(`^[a-z]{2}(?:-[a-z0-9]+)*$`)
|
||||
|
||||
func isValidLocaleTag(locale string) bool {
|
||||
return localeTagPattern.MatchString(locale)
|
||||
}
|
||||
|
||||
// ValidateLocalizationPath accepts only slash-separated plugin-relative paths.
|
||||
func ValidateLocalizationPath(catalogPath string) error {
|
||||
if strings.TrimSpace(catalogPath) == "" {
|
||||
return fmt.Errorf("catalog path is required")
|
||||
}
|
||||
if filepath.IsAbs(catalogPath) || filepath.VolumeName(catalogPath) != "" || strings.HasPrefix(catalogPath, "/") {
|
||||
return fmt.Errorf("catalog path must be relative")
|
||||
}
|
||||
if strings.Contains(catalogPath, `\`) {
|
||||
return fmt.Errorf("catalog path must not contain backslash")
|
||||
}
|
||||
for _, part := range strings.Split(catalogPath, "/") {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("catalog path traversal is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidPluginID(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -233,6 +233,50 @@ func TestValidateManifest_OpenProviders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_Localization(t *testing.T) {
|
||||
base := &Manifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "localized.plugin",
|
||||
Name: "Localized",
|
||||
Version: "1.0.0",
|
||||
APIVersion: "1.0",
|
||||
Provides: []string{"localized.capability"},
|
||||
Permissions: []string{"ui.register"},
|
||||
Localization: &LocalizationConfig{
|
||||
DefaultLocale: "en",
|
||||
Locales: map[string]string{
|
||||
"en": "locales/en.json",
|
||||
"ru": "locales/ru.json",
|
||||
},
|
||||
},
|
||||
}
|
||||
if errs := ValidateManifest(base); len(errs) != 0 {
|
||||
t.Fatalf("valid localization errors = %v", errs)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
localization *LocalizationConfig
|
||||
want string
|
||||
}{
|
||||
{"missing default catalog", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"ru": "locales/ru.json"}}, "defaultLocale"},
|
||||
{"invalid locale", &LocalizationConfig{DefaultLocale: "EN", Locales: map[string]string{"EN": "locales/en.json"}}, "locale"},
|
||||
{"absolute path", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "/tmp/en.json"}}, "relative"},
|
||||
{"traversal", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../en.json"}}, "traversal"},
|
||||
{"backslash", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\en.json`}}, "backslash"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
manifest := *base
|
||||
manifest.Localization = tc.localization
|
||||
combined := strings.Join(ValidateManifest(&manifest), "\n")
|
||||
if !strings.Contains(combined, tc.want) {
|
||||
t.Fatalf("errors = %q, want %q", combined, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
|
|
|
|||
Loading…
Reference in New Issue