Refine v2 plugin API and sync flow

This commit is contained in:
2026-06-27 12:36:31 +08:00
parent 03175aa46d
commit 24444a8588
14 changed files with 1013 additions and 282 deletions
+14
View File
@@ -191,6 +191,20 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked()
}
// UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Sync = syncSettings
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// SetCurrentVault updates the current vault path and adds to recents.
func (m *Manager) SetCurrentVault(path string) error {
m.mu.Lock()
+45
View File
@@ -155,6 +155,51 @@ func TestUpdate_WorkbenchPreferences(t *testing.T) {
}
}
func TestUpdateSync(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{DevMode: true}); err != nil {
t.Fatalf("Update dev mode: %v", err)
}
if err := m.UpdateSync(SyncSettings{
Enabled: true,
ServerURL: "https://sync.example",
DeviceID: "device-1",
DeviceName: "Desktop",
SyncInterval: 15,
LastStatus: "connected",
LastSyncAt: "2026-06-27T00:00:00Z",
LastError: "previous",
}); err != nil {
t.Fatalf("UpdateSync: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if !cfg.Sync.Enabled ||
cfg.Sync.ServerURL != "https://sync.example" ||
cfg.Sync.DeviceID != "device-1" ||
cfg.Sync.DeviceName != "Desktop" ||
cfg.Sync.SyncInterval != 15 ||
cfg.Sync.LastStatus != "connected" ||
cfg.Sync.LastSyncAt != "2026-06-27T00:00:00Z" ||
cfg.Sync.LastError != "previous" {
t.Fatalf("sync settings = %+v", cfg.Sync)
}
if !cfg.DevMode {
t.Fatal("UpdateSync changed DevMode")
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()
+25 -8
View File
@@ -44,6 +44,23 @@ func validatePluginID(pluginID string) error {
return nil
}
func validateStorageName(kind, name string) error {
if name == "" {
return fmt.Errorf("%s name is empty", kind)
}
if strings.ContainsAny(name, `/\`) {
return fmt.Errorf("%s name %q contains path separators", kind, name)
}
if name == "." || name == ".." {
return fmt.Errorf("%s name %q is a path traversal reference", kind, name)
}
cleaned := filepath.Clean(name)
if cleaned != name {
return fmt.Errorf("%s name %q contains path traversal", kind, name)
}
return nil
}
// ─── Atomic write helper ──────────────────────────────────
func atomicWrite(path string, data []byte) error {
@@ -135,8 +152,8 @@ func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interfac
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if name == "" {
return nil, fmt.Errorf("data name is empty")
if err := validateStorageName("data", name); err != nil {
return nil, err
}
dir := s.vault.GetPluginDataPath(pluginID)
@@ -162,8 +179,8 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
if err := validatePluginID(pluginID); err != nil {
return err
}
if name == "" {
return fmt.Errorf("data name is empty")
if err := validateStorageName("data", name); err != nil {
return err
}
dir := s.vault.GetPluginDataPath(pluginID)
@@ -183,8 +200,8 @@ func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interfa
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if name == "" {
return nil, fmt.Errorf("cache name is empty")
if err := validateStorageName("cache", name); err != nil {
return nil, err
}
dir := s.vault.GetPluginCachePath(pluginID)
@@ -210,8 +227,8 @@ func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]in
if err := validatePluginID(pluginID); err != nil {
return err
}
if name == "" {
return fmt.Errorf("cache name is empty")
if err := validateStorageName("cache", name); err != nil {
return err
}
dir := s.vault.GetPluginCachePath(pluginID)
+54
View File
@@ -229,6 +229,60 @@ func TestPathTraversal_Blocked(t *testing.T) {
}
}
func TestPluginDataJSONNameTraversal_Blocked(t *testing.T) {
s, _ := newTestStorage(t)
traversalNames := []string{
"..",
"../evil",
"foo/../../bar",
"/absolute",
`backslash\traverse`,
"nested/name",
}
for _, name := range traversalNames {
t.Run(name, func(t *testing.T) {
err := s.WritePluginDataJSON("data-plugin", name, map[string]interface{}{"x": 1})
if err == nil {
t.Errorf("WritePluginDataJSON(%q): expected error, got nil", name)
}
_, err = s.ReadPluginDataJSON("data-plugin", name)
if err == nil {
t.Errorf("ReadPluginDataJSON(%q): expected error, got nil", name)
}
})
}
}
func TestPluginCacheJSONNameTraversal_Blocked(t *testing.T) {
s, _ := newTestStorage(t)
traversalNames := []string{
"..",
"../evil",
"foo/../../bar",
"/absolute",
`backslash\traverse`,
"nested/name",
}
for _, name := range traversalNames {
t.Run(name, func(t *testing.T) {
err := s.WritePluginCacheJSON("cache-plugin", name, map[string]interface{}{"x": 1})
if err == nil {
t.Errorf("WritePluginCacheJSON(%q): expected error, got nil", name)
}
_, err = s.ReadPluginCacheJSON("cache-plugin", name)
if err == nil {
t.Errorf("ReadPluginCacheJSON(%q): expected error, got nil", name)
}
})
}
}
// ─── Atomic write tests ──────────────────────────────────────
func TestAtomicWrite(t *testing.T) {