feat: load declared plugin locale catalogs
This commit is contained in:
parent
15f1f413d0
commit
8b1151e03a
|
|
@ -2086,12 +2086,75 @@ func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
|
||||||
"version": p.Manifest.Version,
|
"version": p.Manifest.Version,
|
||||||
"entry": p.Manifest.Frontend.Entry,
|
"entry": p.Manifest.Frontend.Entry,
|
||||||
"style": p.Manifest.Frontend.Style,
|
"style": p.Manifest.Frontend.Style,
|
||||||
|
"localization": p.Manifest.Localization,
|
||||||
"rootPath": p.RootPath,
|
"rootPath": p.RootPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"status": "not-found"}
|
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.
|
// GetPluginAssetContent reads a frontend asset file from a plugin directory.
|
||||||
// Security: validates that the assetPath is relative and does not escape the plugin root.
|
// Security: validates that the assetPath is relative and does not escape the plugin root.
|
||||||
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
|
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) {
|
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||||
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
|
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ type Manifest struct {
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Localization *LocalizationConfig `json:"localization,omitempty"`
|
||||||
Provides []string `json:"provides"`
|
Provides []string `json:"provides"`
|
||||||
Requires []string `json:"requires,omitempty"`
|
Requires []string `json:"requires,omitempty"`
|
||||||
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
||||||
|
|
@ -31,6 +33,12 @@ type Manifest struct {
|
||||||
Sync *SyncConfig `json:"sync,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.
|
// FrontendConfig describes the plugin's frontend bundle.
|
||||||
type FrontendConfig struct {
|
type FrontendConfig struct {
|
||||||
Entry string `json:"entry"`
|
Entry string `json:"entry"`
|
||||||
|
|
@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
|
||||||
if len(m.Permissions) == 0 {
|
if len(m.Permissions) == 0 {
|
||||||
errs.add("permissions must have at least one permission")
|
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 {
|
if m.Contributes != nil {
|
||||||
for i, provider := range m.Contributes.OpenProviders {
|
for i, provider := range m.Contributes.OpenProviders {
|
||||||
if provider.ID == "" {
|
if provider.ID == "" {
|
||||||
|
|
@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
|
||||||
return errs.errors
|
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 {
|
func isValidPluginID(id string) bool {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return false
|
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.
|
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||||
dir1 := t.TempDir()
|
dir1 := t.TempDir()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue