From 8b1151e03a020759094278ea77ca30124c99b19e Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 11 Jul 2026 02:02:47 +0800 Subject: [PATCH] feat: load declared plugin locale catalogs --- internal/api/app.go | 77 +++++++++++++++++++++++--- internal/api/app_test.go | 59 ++++++++++++++++++++ internal/core/plugin/plugin.go | 86 +++++++++++++++++++++++------ internal/core/plugin/plugin_test.go | 44 +++++++++++++++ 4 files changed, 242 insertions(+), 24 deletions(-) diff --git a/internal/api/app.go b/internal/api/app.go index 705ee3b..b009bbf 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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) { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 5e8ac00..8fcf860 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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"}) diff --git a/internal/core/plugin/plugin.go b/internal/core/plugin/plugin.go index 60be7f8..30143af 100644 --- a/internal/core/plugin/plugin.go +++ b/internal/core/plugin/plugin.go @@ -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 diff --git a/internal/core/plugin/plugin_test.go b/internal/core/plugin/plugin_test.go index 299b6af..6766803 100644 --- a/internal/core/plugin/plugin_test.go +++ b/internal/core/plugin/plugin_test.go @@ -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()