From 64945c4920bd43998d0f63bf6ab279467a5507fe Mon Sep 17 00:00:00 2001
From: South Al <21022679+kiroo92@users.noreply.github.com>
Date: Sat, 14 Nov 2026 15:20:20 +0000
Subject: [PATCH 1/2] feat(plugin): add persistent local data lifecycle
Provide an SDK data directory retained across ordinary uninstall and package updates. Add administrator inventory, explicit purge, and validated offline ZIP backup and restore with matching documentation and regression coverage.
---
backend/internal/app/runtime_plugin.go | 20 +-
backend/internal/app/testdata/routes.json | 4 +
backend/internal/app/wire_gen.go | 5 +-
.../internal/pkg/locale/error_messages.json | 14 +-
backend/internal/plugin/data.go | 169 +++++++
backend/internal/plugin/data_test.go | 97 ++++
backend/internal/plugin/doc.go | 1 +
backend/internal/plugin/errors.go | 5 +
backend/internal/plugin/helpers_test.go | 2 +-
backend/internal/plugin/httpapi/data.go | 66 +++
backend/internal/plugin/httpapi/data_test.go | 97 ++++
.../plugin/httpapi/management_operations.go | 55 ++-
.../internal/plugin/httpapi/routes_admin.go | 2 +
backend/internal/plugin/manager.go | 22 +-
...ource_removal_scenario_integration_test.go | 2 +-
.../internal/plugin/provider/data_archives.go | 454 ++++++++++++++++++
.../plugin/provider/data_archives_test.go | 220 +++++++++
.../internal/plugin/provider/data_files.go | 181 +++++++
.../plugin/provider/data_files_test.go | 80 +++
backend/internal/plugin/provider/doc.go | 1 +
.../provider/package_repair_scenario_test.go | 4 +-
.../plugin/provider/process_supervisor.go | 14 +-
backend/internal/plugin/runtime.go | 23 +-
backend/internal/plugin/service.go | 16 +-
.../catalog_scenario_integration_test.go | 2 +-
.../installation_scenario_integration_test.go | 4 +-
docs/architecture/index.md | 2 +-
docs/architecture/plugin_system.md | 16 +-
docs/guides/plugins/sdk_reference.md | 10 +-
docs/interfaces/plugin_contracts.md | 17 +-
.../observability_and_data_lifecycle.md | 2 +-
docs/operations/plugin_operations.md | 18 +-
.../api/__tests__/admin.plugins.data.spec.ts | 44 ++
frontend/src/api/admin/plugins.ts | 51 +-
frontend/src/api/admin/plugins.types.ts | 9 +
frontend/src/i18n/locales/en/admin/plugins.ts | 17 +
frontend/src/i18n/locales/zh/admin/plugins.ts | 17 +
.../views/admin/plugins/PluginDataPanel.vue | 242 ++++++++++
.../src/views/admin/plugins/PluginsView.vue | 28 +-
.../plugins/__tests__/PluginDataPanel.spec.ts | 138 ++++++
.../go/internal/testplugins/roundtrip/main.go | 1 +
plugin-sdk/go/pluginpb/v1/plugin.pb.go | 18 +-
.../proto/tokenrouter/plugin/v1/plugin.proto | 4 +-
plugin-sdk/go/sdk/plugin.go | 20 +-
plugin-sdk/go/sdk/serve_external_test.go | 2 +
plugin-sdk/go/sdk/server.go | 14 +-
46 files changed, 2168 insertions(+), 62 deletions(-)
create mode 100644 backend/internal/plugin/data.go
create mode 100644 backend/internal/plugin/data_test.go
create mode 100644 backend/internal/plugin/httpapi/data.go
create mode 100644 backend/internal/plugin/httpapi/data_test.go
create mode 100644 backend/internal/plugin/provider/data_archives.go
create mode 100644 backend/internal/plugin/provider/data_archives_test.go
create mode 100644 backend/internal/plugin/provider/data_files.go
create mode 100644 backend/internal/plugin/provider/data_files_test.go
create mode 100644 frontend/src/api/__tests__/admin.plugins.data.spec.ts
create mode 100644 frontend/src/views/admin/plugins/PluginDataPanel.vue
create mode 100644 frontend/src/views/admin/plugins/__tests__/PluginDataPanel.spec.ts
diff --git a/backend/internal/app/runtime_plugin.go b/backend/internal/app/runtime_plugin.go
index c881166a2..a0f361420 100644
--- a/backend/internal/app/runtime_plugin.go
+++ b/backend/internal/app/runtime_plugin.go
@@ -58,10 +58,24 @@ func providePluginSource(cfg *config.Config, store *postgres.Store, configuratio
// providePluginManager assembles verified storage, installation policy, and process ownership.
// The source is shared with host capabilities to avoid a manager/process dependency
// cycle. Construction leaves reconciliation to the registered application lifecycle hook.
-func providePluginManager(cfg *config.Config, info BuildInfo, source *plugin.DevSource, configuration *plugin.Configuration, packages *provider.PackageFiles, processes *provider.Processes) *plugin.Manager {
+func providePluginManager(
+ cfg *config.Config,
+ info BuildInfo,
+ source *plugin.DevSource,
+ configuration *plugin.Configuration,
+ packages *provider.PackageFiles,
+ processes *provider.Processes,
+) (*plugin.Manager, error) {
+ data, err := provider.NewDataFiles(filepath.Join(cfg.Plugins.Dir, "data"))
+ if err != nil {
+ return nil, err
+ }
return plugin.NewManager(plugin.ManagerOptions{
- Enabled: cfg.Plugins.Enabled, MaxRunning: cfg.Plugins.MaxRunning, HostVersion: info.Version, StateRoot: filepath.Join(cfg.Plugins.Dir, "state"),
- }, source, packages, processes, configuration, slog.Default())
+ Enabled: cfg.Plugins.Enabled,
+ MaxRunning: cfg.Plugins.MaxRunning,
+ HostVersion: info.Version,
+ StateRoot: filepath.Join(cfg.Plugins.Dir, "state"),
+ }, source, packages, processes, configuration, data, slog.Default()), nil
}
// providePluginService binds administration to the same runtime manager and staged uploads.
diff --git a/backend/internal/app/testdata/routes.json b/backend/internal/app/testdata/routes.json
index 0fa6cdb9b..524119cf7 100644
--- a/backend/internal/app/testdata/routes.json
+++ b/backend/internal/app/testdata/routes.json
@@ -15,6 +15,7 @@
"DELETE /api/v1/admin/payment/providers/:id",
"DELETE /api/v1/admin/plugin-indexes/:id",
"DELETE /api/v1/admin/plugins/:id",
+ "DELETE /api/v1/admin/plugins/:id/data",
"DELETE /api/v1/admin/pricing/configs/:id",
"DELETE /api/v1/admin/promo-codes/:id",
"DELETE /api/v1/admin/providers/:id",
@@ -156,8 +157,10 @@
"GET /api/v1/admin/plugins",
"GET /api/v1/admin/plugins/:id",
"GET /api/v1/admin/plugins/:id/config",
+ "GET /api/v1/admin/plugins/:id/data/archive",
"GET /api/v1/admin/plugins/:id/limits",
"GET /api/v1/admin/plugins/:id/logs",
+ "GET /api/v1/admin/plugins/data",
"GET /api/v1/admin/pricing/configs",
"GET /api/v1/admin/pricing/configs/:id",
"GET /api/v1/admin/pricing/defaults",
@@ -442,6 +445,7 @@
"POST /api/v1/admin/plugin-catalog/refresh",
"POST /api/v1/admin/plugin-indexes",
"POST /api/v1/admin/plugins",
+ "POST /api/v1/admin/plugins/:id/data/archive",
"POST /api/v1/admin/plugins/:id/disable",
"POST /api/v1/admin/plugins/:id/enable",
"POST /api/v1/admin/plugins/:id/restart",
diff --git a/backend/internal/app/wire_gen.go b/backend/internal/app/wire_gen.go
index 51e642fe6..fc5d44f73 100644
--- a/backend/internal/app/wire_gen.go
+++ b/backend/internal/app/wire_gen.go
@@ -471,7 +471,10 @@ func initializeApplication(ctx context.Context, cfg *config.Config, info BuildIn
redeemCodes := providePluginRedeemCodes(writes, grants, plans, redeemService)
hostServices := providePluginHostServices(walletService, subscriptions, queries, emails, captchaVerifier, kv, redeemCodes, grants, auditLogService)
processes := providePluginProcesses(cfg, info, hostServices)
- pluginManager := providePluginManager(cfg, info, devSource, configuration, packageFiles, processes)
+ pluginManager, err := providePluginManager(cfg, info, devSource, configuration, packageFiles, processes)
+ if err != nil {
+ return nil, err
+ }
catalogService, err := providePluginCatalog(cfg, info, store2, devSource)
if err != nil {
return nil, err
diff --git a/backend/internal/pkg/locale/error_messages.json b/backend/internal/pkg/locale/error_messages.json
index dec5e9ac8..3deb90d2c 100644
--- a/backend/internal/pkg/locale/error_messages.json
+++ b/backend/internal/pkg/locale/error_messages.json
@@ -180,7 +180,12 @@
"MAX_RUNNING_EXCEEDED": "Plugin process limit reached",
"PLUGIN_INDEX_SIGNATURE_INVALID": "Plugin index signature is invalid",
"PLUGIN_CONFIG_INVALID": "Plugin configuration is invalid",
- "PLUGIN_PACKAGE_MISSING": "Plugin package is missing; upload the same version again"
+ "PLUGIN_PACKAGE_MISSING": "Plugin package is missing; upload the same version again",
+ "PLUGIN_DATA_REQUIRES_STOP": "Disable the plugin before backing up or restoring its data.",
+ "PLUGIN_DATA_NOT_FOUND": "Plugin data not found.",
+ "PLUGIN_DATA_EXISTS": "Plugin data directory is not empty.",
+ "PLUGIN_DATA_INVALID": "Plugin data archive or directory is invalid.",
+ "PLUGIN_DATA_TOO_LARGE": "Plugin data exceeds archive limits."
},
"zh-Hans": {
"HTTP_400": "请求参数无效,请检查后重试。",
@@ -363,6 +368,11 @@
"MAX_RUNNING_EXCEEDED": "已达到插件进程数量上限。",
"PLUGIN_INDEX_SIGNATURE_INVALID": "插件索引签名无效。",
"PLUGIN_CONFIG_INVALID": "插件配置无效,请检查字段。",
- "PLUGIN_PACKAGE_MISSING": "插件安装包丢失,请重新上传相同版本。"
+ "PLUGIN_PACKAGE_MISSING": "插件安装包丢失,请重新上传相同版本。",
+ "PLUGIN_DATA_REQUIRES_STOP": "备份或恢复数据前请先停用插件。",
+ "PLUGIN_DATA_NOT_FOUND": "插件数据不存在。",
+ "PLUGIN_DATA_EXISTS": "插件数据目录非空,请先备份并清理现有数据。",
+ "PLUGIN_DATA_INVALID": "插件数据备份或目录无效。",
+ "PLUGIN_DATA_TOO_LARGE": "插件数据超过备份大小或条目数量限制。"
}
}
diff --git a/backend/internal/plugin/data.go b/backend/internal/plugin/data.go
new file mode 100644
index 000000000..f755ee4c9
--- /dev/null
+++ b/backend/internal/plugin/data.go
@@ -0,0 +1,169 @@
+package plugin
+
+import (
+ "context"
+ "errors"
+ "io"
+)
+
+// data.go coordinates durable plugin directories with installation ownership.
+// Files survive ordinary uninstall and remain discoverable by their plugin ID.
+// Backup and restore require disabled or uninstalled plugins, and use the same
+// per-plugin lock as installation changes so execution cannot restart midway.
+//
+// The file store owns directory validation and archive integrity. Management
+// owns admission and callback draining before accessing stopped business data.
+// Database KV and local files have separate commits: a failed file purge remains
+// discoverable and can be retried after the installation row has been removed.
+// External databases and object stores retain their operator-managed lifecycle.
+
+const (
+ // DataMaxArchiveBytes bounds temporary disk use for compressed archives and expanded business files.
+ DataMaxArchiveBytes = int64(1 << 30)
+)
+
+// DataUsage describes local business files without exposing their absolute paths.
+type DataUsage struct {
+ PluginID string `json:"plugin_id"` // publisher-qualified data owner
+ Bytes int64 `json:"bytes"` // regular file bytes
+ Files int64 `json:"files"` // regular file count
+ // Installed reports record presence; Enabled follows that record's desired
+ // execution state and stays false when the installation has been removed, even
+ // while local business files remain available for reinstallation.
+ Installed bool `json:"installed"` // installation record currently exists
+ Enabled bool `json:"enabled"` // installation currently requests execution
+}
+
+// DataArchive owns a completed immutable backup and its temporary file cleanup.
+type DataArchive struct {
+ // Reader owns the immutable snapshot and its temporary file. Callers close
+ // it after delivery, including interrupted responses, to release that storage.
+ Reader io.ReadCloser
+ Bytes int64 // compressed archive byte length
+}
+
+// DataStorage manages plugin-owned directories and validated offline file archives.
+// @project-doc docs/architecture/plugin_system.md#persistent_data
+type DataStorage interface {
+ // Directory creates the stable directory for id and returns its absolute path.
+ // Callers serialize creation with purge and restore for this plugin. Existing
+ // data remains available across package replacements and ordinary uninstallation.
+ Directory(context.Context, string) (string, error)
+ // List measures retained directories without following links into other owners.
+ List(context.Context) ([]DataUsage, error)
+ // Remove deletes one owner's files after callers drain all plugin work.
+ // Missing directories succeed, allowing cleanup to resume after partial failure.
+ // Retained host financial and notification ledgers belong to their existing stores.
+ Remove(context.Context, string) error
+ // Backup snapshots stopped business files into a checksummed portable archive.
+ // Callers hold the plugin management lock and close the returned reader after
+ // use, releasing its temporary storage even when response delivery fails.
+ Backup(context.Context, string) (*DataArchive, error)
+ // Restore validates a complete archive before publishing an absent or empty directory.
+ // Nonempty destinations are rejected. Callers keep the plugin stopped and hold
+ // its management lock throughout validation and publication of restored files.
+ Restore(context.Context, string, io.Reader) error
+}
+
+// ListData returns durable directory usage including uninstalled plugin owners.
+// Sizes are observations rather than quotas; running plugins may change them.
+// Each record is annotated from current installation intent without reading secrets.
+func (s *Service) ListData(ctx context.Context) ([]DataUsage, error) {
+ items, err := s.manager.data.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ installed, err := s.manager.store.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ byID := make(map[string]Installation, len(installed))
+ for _, item := range installed {
+ byID[item.PluginID] = item
+ }
+ for i := range items {
+ item, ok := byID[items[i].PluginID]
+ items[i].Installed = ok
+ items[i].Enabled = ok && item.Enabled
+ }
+ return items, nil
+}
+
+// PurgeData clears local files and KV for an uninstalled plugin identity.
+// An installed owner must be removed first. The management lock prevents a
+// concurrent reinstall from acquiring the directory while deletion is in progress.
+//
+// Host KV commits before local files are removed, because those stores cannot
+// share a transaction. A filesystem failure leaves the owner discoverable for
+// retry; successful cleanup also removes any package state left behind by an
+// earlier partially completed uninstall. Financial and delivery ledger history remains.
+func (s *Service) PurgeData(ctx context.Context, id string) error {
+ return s.manager.locked(ctx, id, func() error {
+ _, err := s.manager.store.Get(ctx, id)
+ if err == nil {
+ return ErrPluginAlreadyInstalled
+ }
+ if !errors.Is(err, ErrPluginNotFound) {
+ return err
+ }
+ if err := s.manager.drainLocked(ctx, id); err != nil {
+ return err
+ }
+ // Delete host KV before filesystem cleanup so failed file removal remains
+ // visible and retryable without allowing a new executable generation between steps.
+ if err := s.manager.store.Delete(ctx, id, true); err != nil {
+ return err
+ }
+ if err := s.manager.data.Remove(ctx, id); err != nil {
+ return err
+ }
+ return s.manager.syncLocked(ctx, id)
+ })
+}
+
+// BackupData produces a stable snapshot of a disabled or uninstalled owner's files.
+// The returned archive is independent of later execution and belongs to the caller.
+// Installed configuration, host KV, ledgers, and external stores require their respective backups.
+//
+// Stopped intent is checked while holding the same owner lock used by enable and
+// reinstall, then actual process and callback completion is awaited. Only snapshot
+// creation holds that lock; delivery reads an immutable temporary file, allowing
+// later execution without making the downloaded bytes inconsistent.
+func (s *Service) BackupData(ctx context.Context, id string) (*DataArchive, error) {
+ var archive *DataArchive
+ err := s.manager.locked(ctx, id, func() error {
+ if err := s.dataStopped(ctx, id); err != nil {
+ return err
+ }
+ var err error
+ archive, err = s.manager.data.Backup(ctx, id)
+ return err
+ })
+ return archive, err
+}
+
+// RestoreData publishes validated business files while keeping their owner stopped.
+// The archive must match id and the destination must be absent or empty. Current
+// installation configuration and permissions remain governed by ordinary administration.
+func (s *Service) RestoreData(ctx context.Context, id string, reader io.Reader) error {
+ return s.manager.locked(ctx, id, func() error {
+ if err := s.dataStopped(ctx, id); err != nil {
+ return err
+ }
+ return s.manager.data.Restore(ctx, id, reader)
+ })
+}
+
+// dataStopped checks disabled intent and waits for actual executable callback completion.
+// It runs under the owner's management lock. Drain failure leaves files untouched,
+// and an enabled installation requires an administrator to disable it before retrying.
+func (s *Service) dataStopped(ctx context.Context, id string) error {
+ item, err := s.manager.store.Get(ctx, id)
+ if err != nil && !errors.Is(err, ErrPluginNotFound) {
+ return err
+ }
+ if item != nil && item.Enabled {
+ return ErrDataRequiresStop
+ }
+ return s.manager.drainLocked(ctx, id)
+}
diff --git a/backend/internal/plugin/data_test.go b/backend/internal/plugin/data_test.go
new file mode 100644
index 000000000..a0edbef84
--- /dev/null
+++ b/backend/internal/plugin/data_test.go
@@ -0,0 +1,97 @@
+package plugin
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// data_test.go checks persistent file management against installation and process state.
+// Storage callbacks are controlled independently from host KV, exposing ordering,
+// retry, and stopped-owner decisions without starting an executable or external database.
+
+// dataFixture supplies the management observations needed by persistent data tests.
+type dataFixture struct {
+ DataStorage // methods unused by individual scenarios
+ removed []string // local owner purge attempts
+ removeError error // injected filesystem cleanup failure
+ backups int // accepted offline snapshot attempts
+ path string // persistent directory supplied to processes
+}
+
+// Directory returns the fixture's stable path for native process initialization.
+func (f *dataFixture) Directory(context.Context, string) (string, error) {
+ return f.path, nil
+}
+
+// Remove records each cleanup attempt and returns the configured filesystem outcome.
+func (f *dataFixture) Remove(_ context.Context, id string) error {
+ f.removed = append(f.removed, id)
+ return f.removeError
+}
+
+// Backup counts snapshots admitted after management has checked stopped-owner policy.
+func (f *dataFixture) Backup(context.Context, string) (*DataArchive, error) {
+ f.backups++
+ return &DataArchive{Reader: io.NopCloser(strings.NewReader(""))}, nil
+}
+
+// TestDataDirectoryReachesNativeProcess starts one accepted native contribution.
+// Its immutable process specification receives the durable path separately from disposable state, and ordinary uninstall retains it.
+func TestDataDirectoryReachesNativeProcess(t *testing.T) {
+ manager, service, store, processes, _ := managerFixture(t, 1)
+ files := &dataFixture{path: "/fixture/persistent/test.plugin"}
+ manager.data = files
+ require.NoError(t, store.Save(t.Context(), managerInstallation(t, "test.plugin", true)))
+ require.NoError(t, manager.Sync(t.Context(), "test.plugin"))
+ require.Equal(t, files.path, processes.started[0].DataDir)
+ require.NotEqual(t, processes.started[0].StateDir, processes.started[0].DataDir)
+ require.NoError(t, service.Uninstall(t.Context(), "test.plugin", false))
+ require.Empty(t, files.removed)
+}
+
+// TestDataPurgeRetriesAfterCommittedUninstall injects a local deletion failure after host records commit.
+// Retrying the explicit purge removes remaining files without resurrecting installation intent or changing the retained ledger policy.
+func TestDataPurgeRetriesAfterCommittedUninstall(t *testing.T) {
+ manager, service, store, _, _ := managerFixture(t, 1)
+ files := &dataFixture{removeError: errors.New("fixture disk failure")}
+ manager.data = files
+ require.NoError(t, store.Save(t.Context(), managerInstallation(t, "test.plugin", false)))
+ require.ErrorIs(t, service.Uninstall(t.Context(), "test.plugin", true), files.removeError)
+ _, err := store.Get(t.Context(), "test.plugin")
+ require.ErrorIs(t, err, ErrPluginNotFound)
+ files.removeError = nil
+ require.NoError(t, service.PurgeData(t.Context(), "test.plugin"))
+ require.Equal(t, []string{"test.plugin", "test.plugin"}, files.removed)
+ require.Equal(t, []bool{true, true}, store.purge)
+}
+
+// TestDataBackupRequiresDisabledIntentAndCompletedDrain varies desired state and callback completion.
+// Enabled or incompletely drained owners never reach snapshot creation; a stopped owner can produce a snapshot afterward.
+func TestDataBackupRequiresDisabledIntentAndCompletedDrain(t *testing.T) {
+ manager, service, store, processes, _ := managerFixture(t, 1)
+ files := &dataFixture{}
+ manager.data = files
+ item := managerInstallation(t, "test.plugin", true)
+ require.NoError(t, store.Save(t.Context(), item))
+ _, err := service.BackupData(t.Context(), item.PluginID)
+ require.ErrorIs(t, err, ErrDataRequiresStop)
+ require.Zero(t, files.backups)
+ item.Enabled = false
+ require.NoError(t, store.Save(t.Context(), item))
+ processes.stopError = errors.New("fixture callback still active")
+ _, err = service.BackupData(t.Context(), item.PluginID)
+ require.ErrorIs(t, err, processes.stopError)
+ require.Zero(t, files.backups)
+ processes.stopError = nil
+ archive, err := service.BackupData(t.Context(), item.PluginID)
+ require.NoError(t, err)
+ require.NoError(t, archive.Reader.Close())
+ require.Equal(t, 1, files.backups)
+ require.ErrorIs(t, service.PurgeData(t.Context(), item.PluginID), ErrPluginAlreadyInstalled)
+ require.Empty(t, files.removed)
+}
diff --git a/backend/internal/plugin/doc.go b/backend/internal/plugin/doc.go
index a6b31f8db..22f74fd16 100644
--- a/backend/internal/plugin/doc.go
+++ b/backend/internal/plugin/doc.go
@@ -11,6 +11,7 @@
// - catalog.go selects source-qualified releases; catalog_service.go, catalog_refresh.go, and catalog_indexes.go own cache and registration coordination.
// - configuration.go, limits.go, and dev_source.go normalize persistent policy and development records.
// - grant.go, ledger.go, wallet.go, redeem.go, subscriptions.go, and capabilities.go define caller authority, financial rules, and audit collaboration.
+// - data.go coordinates persistent directories, retained-owner cleanup, and offline backup management.
// - http.go defines transport-independent HTTP contribution values.
// - tasks.go schedules declared jobs and retains in-memory execution results.
// - kv.go and kv_maintenance.go enforce isolated JSON storage and sampled quota maintenance.
diff --git a/backend/internal/plugin/errors.go b/backend/internal/plugin/errors.go
index b88fae5a7..d4c98f90b 100644
--- a/backend/internal/plugin/errors.go
+++ b/backend/internal/plugin/errors.go
@@ -29,6 +29,11 @@ var (
ErrIndexNotFound = apperror.NotFound("PLUGIN_INDEX_NOT_FOUND", "Plugin index not found")
ErrSecretKeyMissing = apperror.BadRequest("PLUGIN_SECRET_KEY_REQUIRED", "Configure a persistent security.secret_encryption_key before saving plugin secrets")
ErrLimitsInvalid = apperror.BadRequest("PLUGIN_LIMITS_INVALID", "Plugin limits are invalid")
+ ErrDataRequiresStop = apperror.Conflict("PLUGIN_DATA_REQUIRES_STOP", "Disable the plugin before backing up or restoring its data")
+ ErrDataNotFound = apperror.NotFound("PLUGIN_DATA_NOT_FOUND", "Plugin data not found")
+ ErrDataExists = apperror.Conflict("PLUGIN_DATA_EXISTS", "Plugin data directory is not empty")
+ ErrDataInvalid = apperror.BadRequest("PLUGIN_DATA_INVALID", "Plugin data archive or directory is invalid")
+ ErrDataTooLarge = apperror.BadRequest("PLUGIN_DATA_TOO_LARGE", "Plugin data exceeds archive limits")
ErrMaxRunning = apperror.Conflict("MAX_RUNNING_EXCEEDED", "Plugin process limit reached")
ErrIndexSignatureInvalid = apperror.BadRequest("PLUGIN_INDEX_SIGNATURE_INVALID", "Plugin index signature is invalid")
ErrConfigInvalid = apperror.BadRequest("PLUGIN_CONFIG_INVALID", "Plugin configuration is invalid")
diff --git a/backend/internal/plugin/helpers_test.go b/backend/internal/plugin/helpers_test.go
index bb9426d29..3ecb71934 100644
--- a/backend/internal/plugin/helpers_test.go
+++ b/backend/internal/plugin/helpers_test.go
@@ -278,7 +278,7 @@ func managerFixture(t *testing.T, maximum int) (*Manager, *Service, *memoryInsta
store := newMemoryInstallations()
processes := &processFixture{}
packages := &packageFixture{}
- manager := NewManager(ManagerOptions{Enabled: true, MaxRunning: maximum, HostVersion: "0.5.4", StateRoot: t.TempDir()}, store, packages, processes, NewConfiguration(nil, false), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ manager := NewManager(ManagerOptions{Enabled: true, MaxRunning: maximum, HostVersion: "0.5.4", StateRoot: t.TempDir()}, store, packages, processes, NewConfiguration(nil, false), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
return manager, NewService(manager, nil, nil, nil), store, processes, packages
}
diff --git a/backend/internal/plugin/httpapi/data.go b/backend/internal/plugin/httpapi/data.go
new file mode 100644
index 000000000..f0af78cac
--- /dev/null
+++ b/backend/internal/plugin/httpapi/data.go
@@ -0,0 +1,66 @@
+package httpapi
+
+import (
+ "mime"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+ "github.com/TokenFlux/TokenRouter/internal/server/httpx"
+)
+
+// data.go exposes administrator-only management of persistent plugin files.
+// Inventory includes uninstalled owners, while backup and restore use the core
+// service's stopped-owner checks. File archives remain separate from host SQL
+// backups and ordinary plugin contribution request and response limits.
+
+// ListData returns retained directory usage without exposing absolute filesystem paths.
+func (h *Handler) ListData(c *gin.Context) {
+ items, err := h.service.ListData(c.Request.Context())
+ if httpx.ErrorFrom(c, err) {
+ return
+ }
+ httpx.Success(c, items)
+}
+
+// PurgeData removes an uninstalled owner's local files and retained KV records.
+func (h *Handler) PurgeData(c *gin.Context) {
+ if httpx.ErrorFrom(c, h.service.PurgeData(c.Request.Context(), c.Param("id"))) {
+ return
+ }
+ httpx.Success(c, nil)
+}
+
+// BackupData streams a completed private snapshot after stopped-owner validation succeeds.
+// The archive reader belongs to this response and is closed on interrupted
+// delivery as well. Cache prevention applies to successful and failed responses.
+func (h *Handler) BackupData(c *gin.Context) {
+ c.Header("Cache-Control", "no-store")
+ archive, err := h.service.BackupData(c.Request.Context(), c.Param("id"))
+ if httpx.ErrorFrom(c, err) {
+ return
+ }
+ defer func() { _ = archive.Reader.Close() }()
+ c.DataFromReader(http.StatusOK, archive.Bytes, "application/zip", archive.Reader, map[string]string{
+ "Content-Disposition": mime.FormatMediaType("attachment", map[string]string{"filename": c.Param("id") + "-data.zip"}),
+ "X-Content-Type-Options": "nosniff",
+ })
+}
+
+// RestoreData accepts a bounded raw ZIP for an absent or empty stopped owner.
+// Content validation and publication belong to the persistent file store. Failed
+// archives return stable management errors and never overwrite existing business data.
+func (h *Handler) RestoreData(c *gin.Context) {
+ media, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
+ if err != nil || media != "application/zip" {
+ httpx.ErrorFrom(c, plugin.ErrDataInvalid)
+ return
+ }
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, plugin.DataMaxArchiveBytes+1)
+ defer func() { _ = c.Request.Body.Close() }()
+ if httpx.ErrorFrom(c, h.service.RestoreData(c.Request.Context(), c.Param("id"), c.Request.Body)) {
+ return
+ }
+ httpx.Success(c, nil)
+}
diff --git a/backend/internal/plugin/httpapi/data_test.go b/backend/internal/plugin/httpapi/data_test.go
new file mode 100644
index 000000000..f6119c767
--- /dev/null
+++ b/backend/internal/plugin/httpapi/data_test.go
@@ -0,0 +1,97 @@
+package httpapi
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+ "github.com/TokenFlux/TokenRouter/internal/plugin/provider"
+)
+
+// data_test.go exercises administrative file management through registered HTTP routes.
+// Actual directory and archive adapters verify binary transfer, retained-owner
+// discovery, empty-destination restore, and conflicts independently of contribution RPC limits.
+
+// dataInstallationStore represents an uninstalled owner for retained-data HTTP scenarios.
+type dataInstallationStore struct {
+ plugin.InstallationStore // unused persistence operations
+}
+
+// Get reports missing installation intent while local business files remain retained.
+func (dataInstallationStore) Get(context.Context, string) (*plugin.Installation, error) {
+ return nil, plugin.ErrPluginNotFound
+}
+
+// List reports an empty installation inventory independently of retained local directories.
+func (dataInstallationStore) List(context.Context) ([]plugin.Installation, error) {
+ return nil, nil
+}
+
+// Delete accepts repeated deletion of an already absent installation and its KV.
+func (dataInstallationStore) Delete(context.Context, string, bool) error { return nil }
+
+// TestDataHTTPBackupAndRestoreTransfersLargePrivateFiles downloads a ZIP exceeding ordinary contribution limits.
+// The response stays binary and noncacheable, and restoration reproduces its private bytes after an explicit retained-data purge.
+func TestDataHTTPBackupAndRestoreTransfersLargePrivateFiles(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ base := t.TempDir()
+ files, err := provider.NewDataFiles(filepath.Join(base, "data"))
+ require.NoError(t, err)
+ directory, err := files.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ content := make([]byte, 5<<20)
+ _, err = rand.Read(content)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(directory, "private.bin"), content, 0o600))
+ packages, err := provider.NewPackageFiles(base, provider.DownloadOptions{})
+ require.NoError(t, err)
+ processes := provider.NewProcesses(plugin.RuntimeOptions{}, nil, nil)
+ manager := plugin.NewManager(plugin.ManagerOptions{}, dataInstallationStore{}, packages, processes, plugin.NewConfiguration(nil, false), files, nil)
+ service := plugin.NewService(manager, packages, nil, nil)
+ router := gin.New()
+ RegisterAdminRoutes(router.Group("/api/v1/admin"), NewHandler(service, manager, processes, nil), func(c *gin.Context) { c.Next() })
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins/data", nil))
+ require.Equal(t, http.StatusOK, response.Code)
+ var inventory struct {
+ Data []plugin.DataUsage `json:"data"` // returned retained data inventory
+ }
+ require.NoError(t, json.Unmarshal(response.Body.Bytes(), &inventory))
+ require.Len(t, inventory.Data, 1)
+ require.False(t, inventory.Data[0].Installed)
+ require.NotContains(t, response.Body.String(), base)
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugins/test.plugin/data/archive", nil))
+ require.Equal(t, http.StatusOK, response.Code)
+ require.Equal(t, "application/zip", response.Header().Get("Content-Type"))
+ require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
+ require.Greater(t, response.Body.Len(), 4<<20)
+ archive := bytes.Clone(response.Body.Bytes())
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/test.plugin/data/archive", bytes.NewReader(archive))
+ request.Header.Set("Content-Type", "application/zip")
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ require.Equal(t, http.StatusConflict, response.Code)
+ require.Contains(t, response.Body.String(), "PLUGIN_DATA_EXISTS")
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/api/v1/admin/plugins/test.plugin/data", nil))
+ require.Equal(t, http.StatusOK, response.Code)
+ request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/plugins/test.plugin/data/archive", bytes.NewReader(archive))
+ request.Header.Set("Content-Type", "application/zip")
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ require.Equal(t, http.StatusOK, response.Code)
+ restored, err := os.ReadFile(filepath.Join(directory, "private.bin"))
+ require.NoError(t, err)
+ require.Equal(t, content, restored)
+}
diff --git a/backend/internal/plugin/httpapi/management_operations.go b/backend/internal/plugin/httpapi/management_operations.go
index 410b36ca1..1b14f5946 100644
--- a/backend/internal/plugin/httpapi/management_operations.go
+++ b/backend/internal/plugin/httpapi/management_operations.go
@@ -8,10 +8,61 @@ import (
// management_operations.go registers plugin administration in the shared MCP
// operation catalog. Executable changes and capability policy edits declare
// step-up requirements, which the route registrar also enforces for direct HTTP.
-// Multipart upload uses a browser handoff because MCP requests carry JSON bodies.
+// File uploads use a browser handoff because MCP requests carry JSON bodies.
// ManagementOperations owns stable permission identities and schemas for every installed-plugin administrative route mounted by this module's registrar.
var ManagementOperations = map[string]*adminapi.Operation{
+ "plugins.data.list": adminapi.Define(adminapi.Operation{
+ ID: "plugins.data.list",
+ Module: "plugins",
+ Title: "List retained plugin data",
+ Description: "List local plugin business directories, including uninstalled owners, with byte and file counts. Absolute paths and file contents are omitted.",
+ Method: "GET",
+ Path: "/api/v1/admin/plugins/data",
+ ReadOnly: true,
+ BrowserPath: "/admin/plugins?view=data",
+ Input: adminapi.Arguments("/api/v1/admin/plugins/data", nil, nil),
+ Output: adminapi.JSONResponse((*[]plugin.DataUsage)(nil)),
+ }),
+ "plugins.data.delete": adminapi.Define(adminapi.Operation{
+ ID: "plugins.data.delete",
+ Module: "plugins",
+ Title: "Purge retained plugin data",
+ Description: "Delete an uninstalled plugin's local business files and KV. A concurrent reinstall is excluded; financial and delivery ledgers remain retained.",
+ Method: "DELETE",
+ Path: "/api/v1/admin/plugins/:id/data",
+ Sensitive: true,
+ StepUp: true,
+ BrowserPath: "/admin/plugins?view=data",
+ Input: adminapi.Arguments("/api/v1/admin/plugins/:id/data", nil, nil),
+ }),
+ "plugins.data.backup": adminapi.Define(adminapi.Operation{
+ ID: "plugins.data.backup",
+ Module: "plugins",
+ Title: "Download plugin data backup",
+ Description: "Download a checksummed ZIP of local files for a disabled or uninstalled plugin. Host SQL, configuration, and external storage require separate backups.",
+ Method: "GET",
+ Path: "/api/v1/admin/plugins/:id/data/archive",
+ ReadOnly: true,
+ Sensitive: true,
+ StepUp: true,
+ Response: adminapi.ResponseFile,
+ BrowserPath: "/admin/plugins?view=data",
+ Input: adminapi.Arguments("/api/v1/admin/plugins/:id/data/archive", nil, nil),
+ }),
+ "plugins.data.restore": adminapi.Define(adminapi.Operation{
+ ID: "plugins.data.restore",
+ Module: "plugins",
+ Title: "Restore plugin data backup",
+ Description: "Upload a raw application/zip backup for its matching plugin ID while disabled or uninstalled. The destination must be absent or empty; nonempty data is never overwritten.",
+ Method: "POST",
+ Path: "/api/v1/admin/plugins/:id/data/archive",
+ Sensitive: true,
+ StepUp: true,
+ Response: adminapi.ResponseBrowser,
+ BrowserPath: "/admin/plugins?view=data",
+ Input: adminapi.Arguments("/api/v1/admin/plugins/:id/data/archive", nil, nil),
+ }),
"plugins.list": adminapi.Define(adminapi.Operation{
ID: "plugins.list",
Module: "plugins",
@@ -103,7 +154,7 @@ var ManagementOperations = map[string]*adminapi.Operation{
ID: "plugins.delete",
Module: "plugins",
Title: "Uninstall plugin",
- Description: "Drain executable calls and host callbacks, then remove the installation. purge_data=true also deletes its KV data in the same transaction. Financial and delivery ledger history remains.",
+ Description: "Drain executable calls and host callbacks, then remove the installation. purge_data=true also deletes its KV in the same transaction and then removes local business files; failed file cleanup remains retryable. Financial and delivery ledger history remains.",
Method: "DELETE",
Path: "/api/v1/admin/plugins/:id",
Sensitive: true,
diff --git a/backend/internal/plugin/httpapi/routes_admin.go b/backend/internal/plugin/httpapi/routes_admin.go
index 214ddd315..e755d15dd 100644
--- a/backend/internal/plugin/httpapi/routes_admin.go
+++ b/backend/internal/plugin/httpapi/routes_admin.go
@@ -17,6 +17,8 @@ import (
// @project-doc docs/interfaces/plugin_contracts.md#administration
func RegisterAdminRoutes(admin *gin.RouterGroup, handler *Handler, stepUp gin.HandlerFunc) {
for id, endpoint := range map[string]gin.HandlerFunc{
+ "plugins.data.list": handler.ListData, "plugins.data.delete": handler.PurgeData,
+ "plugins.data.backup": handler.BackupData, "plugins.data.restore": handler.RestoreData,
"plugins.update": handler.Update, "plugins.rollback": handler.Rollback,
"plugins.catalog.list": handler.Catalog, "plugins.catalog.get": handler.CatalogEntry, "plugins.catalog.refresh": handler.RefreshCatalog,
"plugins.indexes.list": handler.Indexes, "plugins.indexes.create": handler.CreateIndex, "plugins.indexes.update": handler.UpdateIndex, "plugins.indexes.delete": handler.DeleteIndex,
diff --git a/backend/internal/plugin/manager.go b/backend/internal/plugin/manager.go
index e94ed3799..6d9331f74 100644
--- a/backend/internal/plugin/manager.go
+++ b/backend/internal/plugin/manager.go
@@ -48,6 +48,7 @@ type Loaded struct {
type Manager struct {
options ManagerOptions // host plugin execution policy
store InstallationStore // durable and development installation source
+ data DataStorage // durable plugin-owned files and offline archives
packages Packages // verified local archive storage
processes Supervisors // child generation lifecycle owner
configuration *Configuration // schema and encrypted settings boundary
@@ -71,7 +72,15 @@ type Manager struct {
// NewManager constructs a dormant reconciler with no filesystem or process effects.
// Installation sources and configuration are shared dependencies. Start loads records
// asynchronously; callers can inspect empty snapshots before initialization completes.
-func NewManager(options ManagerOptions, store InstallationStore, packages Packages, processes Supervisors, configuration *Configuration, logger *slog.Logger) *Manager {
+func NewManager(
+ options ManagerOptions,
+ store InstallationStore,
+ packages Packages,
+ processes Supervisors,
+ configuration *Configuration,
+ data DataStorage,
+ logger *slog.Logger,
+) *Manager {
if logger == nil {
logger = slog.Default()
}
@@ -79,6 +88,7 @@ func NewManager(options ManagerOptions, store InstallationStore, packages Packag
options: options,
store: store,
packages: packages,
+ data: data,
processes: processes,
configuration: configuration,
logger: logger,
@@ -303,12 +313,22 @@ func (m *Manager) syncLocked(ctx context.Context, id string, retain ...PackageRe
if err != nil {
return err
}
+ dataDir := ""
+ if m.data != nil {
+ // Prepare the persistent owner before starting executable code, keeping
+ // business files independent of versioned packages and disposable process state.
+ dataDir, err = m.data.Directory(ctx, id)
+ if err != nil {
+ return err
+ }
+ }
spec := ProcessSpec{
PluginID: id,
Version: want.Version,
Executable: local.Executable,
BinarySHA256: local.BinarySHA256,
StateDir: stateDir,
+ DataDir: dataDir,
ConfigJSON: data,
Permissions: slices.Clone(want.GrantedPermissions),
MemoryMiB: want.Manifest.Resources.MemoryLimit(),
diff --git a/backend/internal/plugin/postgres/source_removal_scenario_integration_test.go b/backend/internal/plugin/postgres/source_removal_scenario_integration_test.go
index 23af79292..307472045 100644
--- a/backend/internal/plugin/postgres/source_removal_scenario_integration_test.go
+++ b/backend/internal/plugin/postgres/source_removal_scenario_integration_test.go
@@ -99,7 +99,7 @@ func TestUpdateRestoresHistoryAfterSourceRemoval(t *testing.T) {
}
manager := plugin.NewManager(plugin.ManagerOptions{
Enabled: true, MaxRunning: 1, HostVersion: "0.5.4", StateRoot: t.TempDir(),
- }, store, sourceRemovalPackages{}, processes, plugin.NewConfiguration(nil, false), logger)
+ }, store, sourceRemovalPackages{}, processes, plugin.NewConfiguration(nil, false), nil, logger)
require.NoError(t, manager.Sync(t.Context(), original.PluginID))
service := plugin.NewService(manager, nil, sourceRemovalResolver{target: candidate}, nil)
// Candidate replacement deletes its source after persistence, forcing recovery
diff --git a/backend/internal/plugin/provider/data_archives.go b/backend/internal/plugin/provider/data_archives.go
new file mode 100644
index 000000000..1e8e6bc94
--- /dev/null
+++ b/backend/internal/plugin/provider/data_archives.go
@@ -0,0 +1,454 @@
+package provider
+
+import (
+ "archive/zip"
+ "compress/flate"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+)
+
+// data_archives.go snapshots stopped plugin files and validates portable restores.
+// Each ZIP binds its plugin ID and relative file names to a versioned manifest
+// with byte counts and SHA-256 digests. Private temporary files keep complete
+// snapshots independent of later plugin execution and response interruptions.
+//
+// Restore first receives and validates the complete archive in bounded staging.
+// Files are extracted beneath a scoped root, with links, duplicate names, special
+// files, unexpected entries, and excessive expansion rejected. Publication renames
+// that completed directory into an absent or empty destination, leaving existing
+// business files untouched on validation failure. The manager keeps execution
+// stopped and excludes reinstall, purge, and enable throughout this operation.
+
+const (
+ // Archive limits bound disk consumption and metadata processing for administrator
+ // imports and exports, independently of ordinary plugin contribution HTTP limits.
+ dataMaxBytes = plugin.DataMaxArchiveBytes
+ dataMaxEntries = 10000
+ dataMaxManifestBytes = 4 << 20
+)
+
+// dataManifest binds the exported business files to one plugin identity.
+type dataManifest struct {
+ Format int `json:"format"` // archive format version
+ PluginID string `json:"plugin_id"` // persistent data owner
+ Entries []dataEntry `json:"entries"` // ordered files and directories
+}
+
+// dataEntry records one portable path and its expected restored content.
+type dataEntry struct {
+ Path string `json:"path"` // slash-separated relative path
+ // Directory, Bytes, and SHA256 describe the same entry. Directories have zero
+ // bytes and no digest; regular files declare their decoded size and content hash.
+ Directory bool `json:"directory,omitempty"` // entry represents directory
+ Bytes int64 `json:"bytes"` // uncompressed regular file bytes
+ SHA256 string `json:"sha256,omitempty"` // hexadecimal file content digest
+}
+
+// dataArchiveFile removes a temporary snapshot when its response reader closes.
+type dataArchiveFile struct {
+ *os.File // owned immutable snapshot file
+}
+
+// dataContextReader observes cancellation between bounded archive read operations.
+type dataContextReader struct {
+ ctx context.Context // archive operation cancellation
+ reader io.Reader // underlying bounded input
+}
+
+// Backup builds a complete checksummed snapshot before exposing its response stream.
+// Callers keep the plugin stopped during creation. Snapshot files are private,
+// and closing the returned reader releases them after delivery or cancellation.
+//
+// Files and their content identities are collected under one stopped-owner lock.
+// The ZIP central directory is finalized before the result becomes visible, so
+// disk failures cannot produce a successful partial download. A later enable can
+// resume execution after snapshot creation because subsequent response reads use
+// the completed temporary file rather than live plugin business files.
+func (d *DataFiles) Backup(ctx context.Context, id string) (*plugin.DataArchive, error) {
+ owner, err := d.ownerRoot(ctx, id, false)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = owner.Close() }()
+ file, err := os.CreateTemp(d.root, ".data-snapshot-*.zip")
+ if err != nil {
+ return nil, err
+ }
+ archive := &dataArchiveFile{File: file}
+ complete := false
+ // Until the ZIP is finalized, this call owns temporary-file cleanup; failed
+ // cleanup remains under the reserved startup-reclaimed prefix for later removal.
+ defer func() {
+ if !complete {
+ _ = archive.Close()
+ }
+ }()
+ writer := zip.NewWriter(file)
+ metadata := dataManifest{Format: 1, PluginID: id, Entries: []dataEntry{}}
+ var total int64
+ err = fs.WalkDir(owner.FS(), ".", func(name string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if name == "." {
+ return nil
+ }
+ if !validDataPath(name) {
+ return plugin.ErrDataInvalid
+ }
+ if len(metadata.Entries) >= dataMaxEntries {
+ return plugin.ErrDataTooLarge
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ item := dataEntry{Path: name, Directory: info.IsDir()}
+ archiveName := "data/" + name
+ if item.Directory {
+ archiveName += "/"
+ } else if !info.Mode().IsRegular() {
+ return plugin.ErrDataInvalid
+ }
+ if info.Size() < 0 || (!item.Directory && info.Size() > dataMaxBytes-total) {
+ return plugin.ErrDataTooLarge
+ }
+ destination, err := writer.Create(archiveName)
+ if err != nil {
+ return err
+ }
+ if !item.Directory {
+ item.Bytes, item.SHA256, err = writeDataFile(ctx, owner, name, destination, info.Size())
+ if err != nil {
+ return err
+ }
+ total += item.Bytes
+ }
+ metadata.Entries = append(metadata.Entries, item)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ encoded, err := json.Marshal(metadata)
+ if err != nil {
+ return nil, err
+ }
+ if len(encoded) > dataMaxManifestBytes {
+ return nil, plugin.ErrDataTooLarge
+ }
+ destination, err := writer.Create("manifest.json")
+ if err != nil {
+ return nil, err
+ }
+ if _, err := destination.Write(encoded); err != nil {
+ return nil, err
+ }
+ // Finish the central directory before returning a snapshot, so write errors
+ // cannot turn a partially transferred archive into a successful management response.
+ if err := writer.Close(); err != nil {
+ return nil, err
+ }
+ info, err := file.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if info.Size() > dataMaxBytes {
+ return nil, plugin.ErrDataTooLarge
+ }
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ return nil, err
+ }
+ complete = true
+ return &plugin.DataArchive{Reader: archive, Bytes: info.Size()}, nil
+}
+
+// writeDataFile copies a bounded stopped file and returns its exact content identity.
+// The size observed before opening must match the copied bytes. The caller owns
+// the archive writer, while this function closes its scoped source descriptor.
+func writeDataFile(ctx context.Context, root *os.Root, name string, destination io.Writer, size int64) (int64, string, error) {
+ source, err := root.Open(name)
+ if err != nil {
+ return 0, "", err
+ }
+ defer func() { _ = source.Close() }()
+ digest := sha256.New()
+ written, err := io.Copy(io.MultiWriter(destination, digest), io.LimitReader(dataContextReader{ctx: ctx, reader: source}, size+1))
+ if err != nil {
+ return 0, "", err
+ }
+ if written != size {
+ return 0, "", plugin.ErrDataInvalid
+ }
+ return written, hex.EncodeToString(digest.Sum(nil)), nil
+}
+
+// Restore validates an archive and publishes its files into an absent or empty owner.
+// The complete upload is spooled before ZIP inspection, and extraction occurs in
+// a private directory on the destination filesystem. Existing nonempty data always
+// returns a conflict; administrators retain its backup before removing that owner.
+//
+// The final rename publishes only files whose manifest identity, byte counts,
+// checksums, paths, and expansion limits have passed. Validation or disk failures
+// discard staging and leave destination contents untouched. Callers serialize
+// this operation with plugin execution and installation changes for the same ID.
+func (d *DataFiles) Restore(ctx context.Context, id string, source io.Reader) error {
+ if _, err := d.destination(ctx, id); err != nil {
+ return err
+ }
+ file, err := os.CreateTemp(d.root, ".data-upload-*.zip")
+ if err != nil {
+ return err
+ }
+ defer func() { _ = (&dataArchiveFile{File: file}).Close() }()
+ written, err := io.Copy(file, io.LimitReader(dataContextReader{ctx: ctx, reader: source}, dataMaxBytes+1))
+ if err != nil {
+ return err
+ }
+ if written > dataMaxBytes {
+ return plugin.ErrDataTooLarge
+ }
+ archive, err := zip.NewReader(file, written)
+ if err != nil {
+ return plugin.ErrDataInvalid.WithCause(err)
+ }
+ metadata, entries, err := readDataManifest(archive, id)
+ if err != nil {
+ return err
+ }
+ stage, err := os.MkdirTemp(d.root, ".data-restore-")
+ if err != nil {
+ return err
+ }
+ // Failed extraction keeps only private staging. Cleanup failures leave its
+ // reserved name for startup reclamation while preserving the original restore error.
+ defer func() { _ = os.RemoveAll(stage) }()
+ root, err := os.OpenRoot(stage)
+ if err != nil {
+ return err
+ }
+ err = extractData(ctx, root, metadata, entries)
+ closeErr := root.Close()
+ if err := errors.Join(err, closeErr); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ empty, err := d.destination(ctx, id)
+ if err != nil {
+ return err
+ }
+ parent, err := os.OpenRoot(d.root)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = parent.Close() }()
+ if empty {
+ // Removing an empty directory supports Windows rename behavior; this can
+ // lose only an empty placeholder if publication fails, never existing business files.
+ if err := parent.Remove(id); err != nil {
+ return err
+ }
+ }
+ return parent.Rename(filepath.Base(stage), id)
+}
+
+// destination rejects populated or linked owner directories before restore publication.
+// Its boolean reports an existing empty directory. Missing destinations are valid,
+// and the caller rechecks under the management lock immediately before rename.
+func (d *DataFiles) destination(ctx context.Context, id string) (bool, error) {
+ owner, err := d.ownerRoot(ctx, id, false)
+ if errors.Is(err, plugin.ErrDataNotFound) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = owner.Close() }()
+ entries, err := fs.ReadDir(owner.FS(), ".")
+ if err != nil {
+ return false, err
+ }
+ if len(entries) > 0 {
+ return false, plugin.ErrDataExists
+ }
+ return true, nil
+}
+
+// readDataManifest checks owner identity and the archive's complete declared entry set.
+// Only a bounded version-one manifest is accepted. Duplicate names, file aliases,
+// links, unsupported modes, and undeclared ZIP entries fail before extraction begins.
+//
+// The manifest must account for every ZIP member exactly once so aliases and
+// additional payloads cannot bypass content checks. Expanded byte counts are
+// accumulated before file creation, and each regular file must declare a SHA-256
+// digest that extraction later compares against its actual decompressed bytes.
+func readDataManifest(archive *zip.Reader, id string) (dataManifest, map[string]*zip.File, error) {
+ var metadata dataManifest
+ if len(archive.File) > dataMaxEntries+1 {
+ return metadata, nil, plugin.ErrDataTooLarge
+ }
+ if len(archive.File) == 0 {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ entries := make(map[string]*zip.File, len(archive.File))
+ for _, entry := range archive.File {
+ if _, duplicate := entries[entry.Name]; duplicate || entry.Mode()&^(os.ModePerm|os.ModeDir) != 0 {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ entries[entry.Name] = entry
+ }
+ manifestFile, ok := entries["manifest.json"]
+ if !ok || manifestFile.UncompressedSize64 > dataMaxManifestBytes {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ reader, err := manifestFile.Open()
+ if err != nil {
+ return metadata, nil, plugin.ErrDataInvalid.WithCause(err)
+ }
+ encoded, err := io.ReadAll(io.LimitReader(reader, dataMaxManifestBytes+1))
+ closeErr := reader.Close()
+ if err := errors.Join(err, closeErr); err != nil {
+ return metadata, nil, plugin.ErrDataInvalid.WithCause(err)
+ }
+ if len(encoded) > dataMaxManifestBytes ||
+ json.Unmarshal(encoded, &metadata) != nil ||
+ metadata.Format != 1 || metadata.PluginID != id ||
+ len(metadata.Entries) != len(entries)-1 {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ seen := make(map[string]bool, len(metadata.Entries))
+ var total int64
+ for _, item := range metadata.Entries {
+ if !validDataPath(item.Path) || seen[item.Path] || item.Bytes < 0 || item.Bytes > dataMaxBytes-total {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ seen[item.Path] = true
+ name := "data/" + item.Path
+ if item.Directory {
+ name += "/"
+ if item.Bytes != 0 || item.SHA256 != "" {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ } else {
+ digest, err := hex.DecodeString(item.SHA256)
+ if err != nil || len(digest) != sha256.Size {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ }
+ entry, ok := entries[name]
+ if !ok || entry.UncompressedSize64 != uint64(item.Bytes) ||
+ entry.FileInfo().IsDir() != item.Directory ||
+ (!item.Directory && !entry.Mode().IsRegular()) {
+ return metadata, nil, plugin.ErrDataInvalid
+ }
+ total += item.Bytes
+ }
+ return metadata, entries, nil
+}
+
+// extractData restores verified entries beneath a private root and checks content hashes.
+// Callers own publication and discard the entire staging directory on error. File
+// bytes are bounded again during decompression, independently of claimed ZIP sizes.
+func extractData(ctx context.Context, root *os.Root, metadata dataManifest, entries map[string]*zip.File) error {
+ for _, item := range metadata.Entries {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if item.Directory {
+ if err := root.MkdirAll(item.Path, 0o700); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := root.MkdirAll(path.Dir(item.Path), 0o700); err != nil {
+ return err
+ }
+ if err := extractDataFile(ctx, root, item, entries["data/"+item.Path]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// extractDataFile writes one bounded regular file and validates its expected SHA-256.
+// Descriptors are closed before returning so publication never includes open staged
+// writers. Disk and checksum failures leave cleanup to the enclosing restore operation.
+//
+// Archive metadata alone cannot establish the decoded content: the copy bounds
+// actual bytes and computes their digest while writing. Before publication, the
+// byte count and digest are compared with the manifest before flushing and
+// closing the destination, so failed writeback or corrupt compressed input cannot expose
+// a seemingly successful restored business file.
+func extractDataFile(ctx context.Context, root *os.Root, item dataEntry, entry *zip.File) error {
+ source, err := entry.Open()
+ if err != nil {
+ return plugin.ErrDataInvalid.WithCause(err)
+ }
+ defer func() { _ = source.Close() }()
+ destination, err := root.OpenFile(item.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ digest := sha256.New()
+ written, copyErr := io.Copy(io.MultiWriter(destination, digest), io.LimitReader(dataContextReader{ctx: ctx, reader: source}, item.Bytes+1))
+ if copyErr == nil && (written != item.Bytes || !strings.EqualFold(hex.EncodeToString(digest.Sum(nil)), item.SHA256)) {
+ copyErr = plugin.ErrDataInvalid
+ }
+ var malformed flate.CorruptInputError
+ if errors.As(copyErr, &malformed) || errors.Is(copyErr, zip.ErrChecksum) ||
+ errors.Is(copyErr, zip.ErrFormat) || errors.Is(copyErr, io.ErrUnexpectedEOF) {
+ // Damaged compressed input is a bad archive; preserve write and cancellation
+ // errors separately so storage failures are not reported as invalid uploads.
+ copyErr = plugin.ErrDataInvalid.WithCause(copyErr)
+ }
+ var syncErr error
+ if copyErr == nil {
+ // Flush validated bytes before publication so writeback errors cannot follow
+ // a successful restore, while corrupt partial files need no additional writeback.
+ syncErr = destination.Sync()
+ }
+ closeErr := destination.Close()
+ return errors.Join(copyErr, syncErr, closeErr)
+}
+
+// validDataPath accepts bounded relative paths for archive business files.
+// Path metadata and directory depth are capped across platforms. Separators and
+// colon syntax that could change ownership during extraction are rejected.
+func validDataPath(name string) bool {
+ return name != "." && len(name) <= 512 && strings.Count(name, "/") < 20 &&
+ fs.ValidPath(name) && path.Clean(name) == name &&
+ !strings.ContainsAny(name, "\\:\x00")
+}
+
+// Close releases the snapshot descriptor and removes its private temporary file.
+// Callers invoke it once after delivery or cancellation. Removal is attempted
+// even if descriptor closing fails, and both errors remain available to callers.
+func (f *dataArchiveFile) Close() error {
+ name := f.Name()
+ closeErr := f.File.Close()
+ return errors.Join(closeErr, os.Remove(name))
+}
+
+// Read observes archive cancellation before delegating each bounded input read.
+func (r dataContextReader) Read(buffer []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return r.reader.Read(buffer)
+}
diff --git a/backend/internal/plugin/provider/data_archives_test.go b/backend/internal/plugin/provider/data_archives_test.go
new file mode 100644
index 000000000..13eb85451
--- /dev/null
+++ b/backend/internal/plugin/provider/data_archives_test.go
@@ -0,0 +1,220 @@
+package provider
+
+import (
+ "archive/zip"
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "testing/iotest"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+)
+
+// data_archives_test.go exercises checksummed snapshots using actual ZIP and files.
+// Round trips preserve binary payloads, SQLite companion files, and empty directories.
+// Corrupt or hostile uploads must leave the destination unpublished or unchanged.
+
+// TestDataArchivesRoundTripStoppedFiles snapshots binary files and an empty directory.
+// Restoring into a fresh store reproduces all bytes, and an already populated destination rejects replacement.
+func TestDataArchivesRoundTripStoppedFiles(t *testing.T) {
+ source, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ directory, err := source.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ files := map[string][]byte{
+ "plugin.sqlite": {0, 1, 2, 0xff},
+ "plugin.sqlite-wal": []byte("committed WAL records"),
+ "images/private.png": []byte("private attachment bytes"),
+ }
+ for name, content := range files {
+ filename := filepath.Join(directory, filepath.FromSlash(name))
+ require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o700))
+ require.NoError(t, os.WriteFile(filename, content, 0o600))
+ }
+ require.NoError(t, os.Mkdir(filepath.Join(directory, "empty"), 0o700))
+ archive, err := source.Backup(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ content, err := io.ReadAll(archive.Reader)
+ require.NoError(t, err)
+ require.Equal(t, archive.Bytes, int64(len(content)))
+ require.NoError(t, archive.Reader.Close())
+ target, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ require.NoError(t, target.Restore(t.Context(), "test.plugin", bytes.NewReader(content)))
+ for name, expected := range files {
+ actual, err := os.ReadFile(filepath.Join(target.root, "test.plugin", filepath.FromSlash(name)))
+ require.NoError(t, err)
+ require.Equal(t, expected, actual)
+ }
+ require.DirExists(t, filepath.Join(target.root, "test.plugin", "empty"))
+ require.ErrorIs(t, target.Restore(t.Context(), "test.plugin", bytes.NewReader(content)), plugin.ErrDataExists)
+ items, err := os.ReadDir(source.root)
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+}
+
+// TestDataArchivesRejectInvalidContent builds archives with traversal, identity, and digest violations.
+// Every validation failure keeps the owner's existing empty directory free of partially restored files.
+func TestDataArchivesRejectInvalidContent(t *testing.T) {
+ content := []byte("business data")
+ digest := sha256.Sum256(content)
+ valid := dataEntry{Path: "plugin.sqlite", Bytes: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}
+ for _, test := range []struct {
+ name string // failing archive scenario
+ owner string // declared archive owner
+ entry dataEntry // untrusted manifest entry
+ extra bool // append undeclared payload
+ }{
+ {name: "wrong owner", owner: "test.other", entry: valid},
+ {name: "traversal", owner: "test.plugin", entry: dataEntry{Path: "../escape", Bytes: valid.Bytes, SHA256: valid.SHA256}},
+ {name: "absolute", owner: "test.plugin", entry: dataEntry{Path: "/escape", Bytes: valid.Bytes, SHA256: valid.SHA256}},
+ {name: "backslash", owner: "test.plugin", entry: dataEntry{Path: "dir\\escape", Bytes: valid.Bytes, SHA256: valid.SHA256}},
+ {name: "checksum", owner: "test.plugin", entry: dataEntry{Path: valid.Path, Bytes: valid.Bytes, SHA256: hex.EncodeToString(make([]byte, sha256.Size))}},
+ {name: "size", owner: "test.plugin", entry: dataEntry{Path: valid.Path, Bytes: valid.Bytes + 1, SHA256: valid.SHA256}},
+ {name: "expansion", owner: "test.plugin", entry: dataEntry{Path: valid.Path, Bytes: dataMaxBytes + 1, SHA256: valid.SHA256}},
+ {name: "extra file", owner: "test.plugin", entry: valid, extra: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ target, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ directory, err := target.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ archive := dataTestArchive(t, dataManifest{Format: 1, PluginID: test.owner, Entries: []dataEntry{test.entry}}, map[string][]byte{"data/" + test.entry.Path: content}, test.extra)
+ require.Error(t, target.Restore(t.Context(), "test.plugin", bytes.NewReader(archive)))
+ entries, err := os.ReadDir(directory)
+ require.NoError(t, err)
+ require.Empty(t, entries)
+ entries, err = os.ReadDir(target.root)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ })
+ }
+}
+
+// TestDataArchivesRejectLinkedFiles creates a link to a different owner's private file.
+// Backup rejects the link and removes its temporary snapshot, leaving the target's original bytes untouched.
+func TestDataArchivesRejectLinkedFiles(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("creating symbolic links requires platform-specific privileges")
+ }
+ data, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ directory, err := data.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ other, err := data.Directory(t.Context(), "test.other")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(other, "private"), []byte("secret"), 0o600))
+ require.NoError(t, os.Symlink(filepath.Join(other, "private"), filepath.Join(directory, "alias")))
+ _, err = data.Backup(t.Context(), "test.plugin")
+ require.ErrorIs(t, err, plugin.ErrDataInvalid)
+ entries, err := os.ReadDir(data.root)
+ require.NoError(t, err)
+ require.Len(t, entries, 2)
+}
+
+// TestDataArchivesRejectDuplicateAndLinkedMembers supplies ambiguous or executable filesystem metadata.
+// Restore rejects duplicate member names, symbolic links, and device modes before publishing any destination files.
+func TestDataArchivesRejectDuplicateAndLinkedMembers(t *testing.T) {
+ content := []byte("payload")
+ digest := sha256.Sum256(content)
+ metadata := dataManifest{Format: 1, PluginID: "test.plugin", Entries: []dataEntry{{Path: "file", Bytes: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}}}
+ for _, mode := range []os.FileMode{0, os.ModeSymlink, os.ModeDevice} {
+ t.Run(mode.String(), func(t *testing.T) {
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ header := &zip.FileHeader{Name: "data/file"}
+ header.SetMode(mode | 0o600)
+ entry, err := writer.CreateHeader(header)
+ require.NoError(t, err)
+ _, err = entry.Write(content)
+ require.NoError(t, err)
+ if mode == 0 {
+ _, err := writer.Create("data/file")
+ require.NoError(t, err)
+ }
+ entry, err = writer.Create("manifest.json")
+ require.NoError(t, err)
+ require.NoError(t, json.NewEncoder(entry).Encode(metadata))
+ require.NoError(t, writer.Close())
+ target, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ require.ErrorIs(t, target.Restore(t.Context(), "test.plugin", bytes.NewReader(buffer.Bytes())), plugin.ErrDataInvalid)
+ require.NoDirExists(t, filepath.Join(target.root, "test.plugin"))
+ })
+ }
+}
+
+// TestDataArchivesInterruptedUploadLeavesExistingDirectoryEmpty fails after receiving a partial upload.
+// The original read error propagates and private upload staging is removed, allowing a later complete restore to retry safely.
+func TestDataArchivesInterruptedUploadLeavesExistingDirectoryEmpty(t *testing.T) {
+ target, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ directory, err := target.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ reader := io.MultiReader(bytes.NewReader([]byte("partial ZIP")), iotest.ErrReader(io.ErrUnexpectedEOF))
+ require.ErrorIs(t, target.Restore(t.Context(), "test.plugin", reader), io.ErrUnexpectedEOF)
+ entries, err := os.ReadDir(directory)
+ require.NoError(t, err)
+ require.Empty(t, entries)
+ entries, err = os.ReadDir(target.root)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+}
+
+// TestDataArchivesCorruptZIPChecksumReturnsValidationError changes stored payload bytes after archive creation.
+// The decoded member fails ZIP integrity checks, returns the stable invalid-archive error, and never publishes a business directory.
+func TestDataArchivesCorruptZIPChecksumReturnsValidationError(t *testing.T) {
+ content := []byte("unique stopped SQLite payload")
+ digest := sha256.Sum256(content)
+ metadata := dataManifest{Format: 1, PluginID: "test.plugin", Entries: []dataEntry{{Path: "file", Bytes: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}}}
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ entry, err := writer.CreateHeader(&zip.FileHeader{Name: "data/file", Method: zip.Store})
+ require.NoError(t, err)
+ _, err = entry.Write(content)
+ require.NoError(t, err)
+ entry, err = writer.Create("manifest.json")
+ require.NoError(t, err)
+ require.NoError(t, json.NewEncoder(entry).Encode(metadata))
+ require.NoError(t, writer.Close())
+ archive := buffer.Bytes()
+ position := bytes.Index(archive, content)
+ require.GreaterOrEqual(t, position, 0)
+ archive[position] ^= 0xff
+ target, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ require.ErrorIs(t, target.Restore(t.Context(), "test.plugin", bytes.NewReader(archive)), plugin.ErrDataInvalid)
+ require.NoDirExists(t, filepath.Join(target.root, "test.plugin"))
+}
+
+// dataTestArchive builds a synthetic archive with supplied metadata and untrusted names.
+// Inputs intentionally bypass production export validation so restore checks encounter
+// corrupted content and undeclared entries from an external archive producer.
+func dataTestArchive(t *testing.T, metadata dataManifest, files map[string][]byte, extra bool) []byte {
+ t.Helper()
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ for name, content := range files {
+ entry, err := writer.Create(name)
+ require.NoError(t, err)
+ _, err = entry.Write(content)
+ require.NoError(t, err)
+ }
+ if extra {
+ _, err := writer.Create("data/undeclared")
+ require.NoError(t, err)
+ }
+ entry, err := writer.Create("manifest.json")
+ require.NoError(t, err)
+ require.NoError(t, json.NewEncoder(entry).Encode(metadata))
+ require.NoError(t, writer.Close())
+ return buffer.Bytes()
+}
diff --git a/backend/internal/plugin/provider/data_files.go b/backend/internal/plugin/provider/data_files.go
new file mode 100644
index 000000000..8f9997403
--- /dev/null
+++ b/backend/internal/plugin/provider/data_files.go
@@ -0,0 +1,181 @@
+package provider
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+ "github.com/TokenFlux/TokenRouter/plugin-sdk/go/manifest"
+)
+
+// data_files.go creates and measures persistent directories owned by plugin IDs.
+// These directories live separately from installed packages and disposable state,
+// so package cleanup leaves business files available to a later installation.
+// The manager serializes creation, restore, and purge for each owner.
+//
+// Filesystem operations use scoped root handles. Owner directories cannot be
+// symbolic links, and usage measurements reject links and special files instead
+// of traversing another owner's storage. This limits host management operations;
+// administrator-trusted plugins still run with the host operating-system user.
+// Temporary snapshot and restore names are reserved outside valid plugin IDs and
+// reclaimed during startup before management requests can create active archives.
+
+// DataFiles owns persistent local directories independently of package installation.
+type DataFiles struct {
+ root string // absolute persistent storage root
+}
+
+// NewDataFiles prepares durable storage and removes interrupted archive staging.
+// Construct one instance before admitting plugin administration. The root belongs
+// to the host, while valid plugin-ID children survive initialization and uninstall.
+func NewDataFiles(directory string) (*DataFiles, error) {
+ absolute, err := filepath.Abs(directory)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(absolute, 0o700); err != nil {
+ return nil, err
+ }
+ root, err := os.OpenRoot(absolute)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = root.Close() }()
+ entries, err := fs.ReadDir(root.FS(), ".")
+ if err != nil {
+ return nil, err
+ }
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), ".data-") {
+ // Only host-reserved names are discarded; valid owner directories keep
+ // business files even when no installation record exists at this startup.
+ if err := root.RemoveAll(entry.Name()); err != nil {
+ return nil, err
+ }
+ }
+ }
+ return &DataFiles{root: absolute}, nil
+}
+
+// Directory creates a private persistent directory for one validated plugin ID.
+// Callers hold the owner's management lock through process initialization. Existing
+// links and nondirectories are rejected before an executable receives the path.
+func (d *DataFiles) Directory(ctx context.Context, id string) (string, error) {
+ root, err := d.ownerRoot(ctx, id, true)
+ if err != nil {
+ return "", err
+ }
+ if err := root.Close(); err != nil {
+ return "", err
+ }
+ return filepath.Join(d.root, id), nil
+}
+
+// List measures retained owners while keeping temporary archive staging private.
+// Directory measurements may change during running plugin writes. A malformed
+// owner fails the request rather than reporting a misleading complete total.
+func (d *DataFiles) List(ctx context.Context) ([]plugin.DataUsage, error) {
+ root, err := os.OpenRoot(d.root)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = root.Close() }()
+ entries, err := fs.ReadDir(root.FS(), ".")
+ if err != nil {
+ return nil, err
+ }
+ items := make([]plugin.DataUsage, 0, len(entries))
+ for _, entry := range entries {
+ if !manifest.ValidID(entry.Name()) {
+ continue
+ }
+ owner, err := d.ownerRoot(ctx, entry.Name(), false)
+ if err != nil {
+ return nil, err
+ }
+ usage := plugin.DataUsage{PluginID: entry.Name()}
+ err = fs.WalkDir(owner.FS(), ".", func(name string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return plugin.ErrDataInvalid
+ }
+ usage.Bytes += info.Size()
+ usage.Files++
+ return nil
+ })
+ closeErr := owner.Close()
+ if err := errors.Join(err, closeErr); err != nil {
+ return nil, err
+ }
+ items = append(items, usage)
+ }
+ return items, nil
+}
+
+// Remove deletes only the validated owner's directory and succeeds when absent.
+// Callers drain executable and host-callback work first. A symlink at the owner
+// name is removed as a link; its target and other owners remain untouched.
+func (d *DataFiles) Remove(ctx context.Context, id string) error {
+ if !manifest.ValidID(id) {
+ return plugin.ErrInvalidArgument
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ root, err := os.OpenRoot(d.root)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = root.Close() }()
+ return root.RemoveAll(id)
+}
+
+// ownerRoot opens a validated owner directory, optionally creating an absent one.
+// The returned handle belongs to the caller. Links and nondirectories fail, and
+// missing data is reported with the stable management error for archive operations.
+func (d *DataFiles) ownerRoot(ctx context.Context, id string, create bool) (*os.Root, error) {
+ if !manifest.ValidID(id) {
+ return nil, plugin.ErrInvalidArgument
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ root, err := os.OpenRoot(d.root)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = root.Close() }()
+ info, err := root.Lstat(id)
+ if errors.Is(err, fs.ErrNotExist) && create {
+ if err := root.Mkdir(id, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
+ return nil, err
+ }
+ info, err = root.Lstat(id)
+ }
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, plugin.ErrDataNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return nil, plugin.ErrDataInvalid
+ }
+ return root.OpenRoot(id)
+}
diff --git a/backend/internal/plugin/provider/data_files_test.go b/backend/internal/plugin/provider/data_files_test.go
new file mode 100644
index 000000000..f27d41ef6
--- /dev/null
+++ b/backend/internal/plugin/provider/data_files_test.go
@@ -0,0 +1,80 @@
+package provider
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/TokenFlux/TokenRouter/internal/plugin"
+)
+
+// data_files_test.go verifies owner-directory persistence and scoped removal.
+// Tests use real private directories to exercise restart discovery, symlink
+// rejection, cancellation, and isolation from disposable package cleanup.
+
+// TestDataFilesSurvivePackageRemovalAndRestart writes business data alongside disposable state.
+// Ordinary package cleanup and rebuilding the file-store adapter retain the owner's bytes and usage.
+func TestDataFilesSurvivePackageRemovalAndRestart(t *testing.T) {
+ base := t.TempDir()
+ data, err := NewDataFiles(filepath.Join(base, "data"))
+ require.NoError(t, err)
+ directory, err := data.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(directory, "plugin.sqlite"), []byte("business records"), 0o600))
+ packages, err := NewPackageFiles(base, DownloadOptions{})
+ require.NoError(t, err)
+ require.NoError(t, packages.Remove(t.Context(), "test.plugin"))
+ restarted, err := NewDataFiles(filepath.Join(base, "data"))
+ require.NoError(t, err)
+ items, err := restarted.List(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, []plugin.DataUsage{{PluginID: "test.plugin", Bytes: 16, Files: 1}}, items)
+ same, err := restarted.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ require.Equal(t, directory, same)
+}
+
+// TestDataFilesRejectEscapingOwners creates another owner's files and a linked alias.
+// Invalid identifiers and linked directories cannot expose those files, while purging the alias retains its target.
+func TestDataFilesRejectEscapingOwners(t *testing.T) {
+ data, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ other, err := data.Directory(t.Context(), "test.other")
+ require.NoError(t, err)
+ filename := filepath.Join(other, "keep")
+ require.NoError(t, os.WriteFile(filename, []byte("private"), 0o600))
+ for _, id := range []string{"../test.other", "", "/tmp/test.plugin", "test.plugin/../test.other"} {
+ _, err := data.Directory(t.Context(), id)
+ require.ErrorIs(t, err, plugin.ErrInvalidArgument)
+ require.ErrorIs(t, data.Remove(t.Context(), id), plugin.ErrInvalidArgument)
+ }
+ if runtime.GOOS == "windows" {
+ t.Skip("creating symbolic links requires platform-specific privileges")
+ }
+ require.NoError(t, os.Symlink(other, filepath.Join(data.root, "test.alias")))
+ _, err = data.Directory(t.Context(), "test.alias")
+ require.ErrorIs(t, err, plugin.ErrDataInvalid)
+ require.NoError(t, data.Remove(t.Context(), "test.alias"))
+ require.FileExists(t, filename)
+ require.NoError(t, data.Remove(t.Context(), "test.other"))
+ require.NoError(t, data.Remove(t.Context(), "test.other"))
+}
+
+// TestDataFilesCancellationPreservesBusinessRecords cancels management before directory cleanup begins.
+// Purging returns the context error and retains existing files for a later explicit retry.
+func TestDataFilesCancellationPreservesBusinessRecords(t *testing.T) {
+ data, err := NewDataFiles(t.TempDir())
+ require.NoError(t, err)
+ directory, err := data.Directory(t.Context(), "test.plugin")
+ require.NoError(t, err)
+ filename := filepath.Join(directory, "keep")
+ require.NoError(t, os.WriteFile(filename, []byte("retained"), 0o600))
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ require.ErrorIs(t, data.Remove(ctx, "test.plugin"), context.Canceled)
+ require.FileExists(t, filename)
+}
diff --git a/backend/internal/plugin/provider/doc.go b/backend/internal/plugin/provider/doc.go
index 7fbc22c5d..14f0eea0c 100644
--- a/backend/internal/plugin/provider/doc.go
+++ b/backend/internal/plugin/provider/doc.go
@@ -10,6 +10,7 @@
// - index_client.go fetches bounded signed catalogs under explicit trust policies.
// - process_*.go handles child commands, platform process rules, resources, and bounded logs.
// - package_*.go handles uploads, downloads, verification, and development directories.
+// - data_files.go and data_archives.go retain local business files and validate stopped-owner snapshots.
// - host_*.go binds capabilities, translates RPCs, observes writes, and tracks actual callback return.
// - grpc_plugin.go implements the go-plugin handshake and broker service connection.
package provider
diff --git a/backend/internal/plugin/provider/package_repair_scenario_test.go b/backend/internal/plugin/provider/package_repair_scenario_test.go
index 5311791dd..483d7e0e7 100644
--- a/backend/internal/plugin/provider/package_repair_scenario_test.go
+++ b/backend/internal/plugin/provider/package_repair_scenario_test.go
@@ -76,7 +76,7 @@ func TestPackageRepairRestoresSameSizeCorruption(t *testing.T) {
processes := NewProcesses(plugin.RuntimeOptions{}, nil, nil)
manager := plugin.NewManager(
plugin.ManagerOptions{Enabled: true, MaxRunning: 1, HostVersion: "0.6.0"},
- &repairInstallationStore{}, packages, processes, plugin.NewConfiguration(nil, false), nil,
+ &repairInstallationStore{}, packages, processes, plugin.NewConfiguration(nil, false), nil, nil,
)
// Cleanup gets its own deadline because the test context is canceled before
// cleanup callbacks run, while lifecycle shutdown must still finish its work.
@@ -130,7 +130,7 @@ func TestForcedSameVersionPackagesRetainIndependentRollbackBytes(t *testing.T) {
require.NoError(t, err)
processes := NewProcesses(plugin.RuntimeOptions{}, nil, nil)
manager := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 1, HostVersion: "0.6.0"},
- &repairInstallationStore{}, packages, processes, plugin.NewConfiguration(nil, false), nil)
+ &repairInstallationStore{}, packages, processes, plugin.NewConfiguration(nil, false), nil, nil)
t.Cleanup(func() { require.NoError(t, manager.Stop(context.Background())) })
service := plugin.NewService(manager, packages, nil, nil)
first, err := packages.Preview(t.Context(), bytes.NewReader(packageArchive(t, "1.0.0")))
diff --git a/backend/internal/plugin/provider/process_supervisor.go b/backend/internal/plugin/provider/process_supervisor.go
index 2e8002a81..9f8306728 100644
--- a/backend/internal/plugin/provider/process_supervisor.go
+++ b/backend/internal/plugin/provider/process_supervisor.go
@@ -564,7 +564,19 @@ func (s *supervisor) start() (_ *generation, resultErr error) {
})
ctx, cancel := context.WithTimeout(s.ctx, s.options.InitTimeout)
defer cancel()
- _, err = conn.services.Plugin.Init(ctx, &pluginpb.InitRequest{HostVersion: s.options.HostVersion, ApiVersion: sdk.ProtocolVersion, PluginId: spec.PluginID, PluginVersion: spec.Version, PreviousVersion: spec.PreviousVersion, InstanceId: s.options.InstanceID, ConfigJson: spec.ConfigJSON, HostBrokerId: brokerID, GrantedPermissions: spec.Permissions, StateDir: spec.StateDir})
+ _, err = conn.services.Plugin.Init(ctx, &pluginpb.InitRequest{
+ HostVersion: s.options.HostVersion,
+ ApiVersion: sdk.ProtocolVersion,
+ PluginId: spec.PluginID,
+ PluginVersion: spec.Version,
+ PreviousVersion: spec.PreviousVersion,
+ InstanceId: s.options.InstanceID,
+ ConfigJson: spec.ConfigJSON,
+ HostBrokerId: brokerID,
+ GrantedPermissions: spec.Permissions,
+ StateDir: spec.StateDir,
+ DataDir: spec.DataDir,
+ })
if err != nil {
return nil, err
}
diff --git a/backend/internal/plugin/runtime.go b/backend/internal/plugin/runtime.go
index 6d27b7c33..ddba99680 100644
--- a/backend/internal/plugin/runtime.go
+++ b/backend/internal/plugin/runtime.go
@@ -94,15 +94,20 @@ type LogReader interface {
// ProcessSpec supplies immutable inputs for starting one plugin generation.
type ProcessSpec struct {
- PluginID string // publisher-qualified installation identifier
- Version string // desired plugin release version
- PreviousVersion string // previously running release version
- Executable string // absolute verified executable path
- BinarySHA256 string // expected executable SHA-256 digest
- StateDir string // absolute disposable process state directory
- ConfigJSON []byte // normalized decrypted configuration object
- Permissions []string // administrator-granted capability names
- MemoryMiB int // advisory process memory limit
+ PluginID string // publisher-qualified installation identifier
+ Version string // desired plugin release version
+ PreviousVersion string // previously running release version
+ Executable string // absolute verified executable path
+ BinarySHA256 string // expected executable SHA-256 digest
+ StateDir string // absolute disposable process state directory
+
+ // DataDir identifies the host-created persistent directory for business files.
+ // Internal callers without local storage leave it empty; production supplies it.
+ DataDir string
+
+ ConfigJSON []byte // normalized decrypted configuration object
+ Permissions []string // administrator-granted capability names
+ MemoryMiB int // advisory process memory limit
// VerifyBinary is false only for trusted development directories; installed release executables require a matching digest before process launch.
VerifyBinary bool
}
diff --git a/backend/internal/plugin/service.go b/backend/internal/plugin/service.go
index ced84f390..975c03618 100644
--- a/backend/internal/plugin/service.go
+++ b/backend/internal/plugin/service.go
@@ -270,11 +270,14 @@ func (s *Service) Disable(ctx context.Context, id string, actor int64) (*ChangeR
// Uninstall drains admitted work before transactionally deleting installation intent and optional KV.
// Drain failure leaves the installation intact with request admission closed for
-// retry. Ledger history always remains, and local files are removed after commit.
+// retry. Ledger history always remains; package files and disposable state are
+// removed after commit. Durable business files remain until explicit data purge.
//
// The same plugin lock spans admission closure, callback completion, store deletion,
-// and local cleanup. An arriving enable or reinstall request therefore cannot
-// create a generation between drain and purge. Missing records still clean managed
+// and local cleanup. A purging uninstall also removes the durable data directory
+// after the host transaction commits; a failed removal remains retryable. An
+// arriving enable or reinstall request cannot create a generation between drain
+// and purge. Missing records still clean managed
// files, supporting retries after a committed deletion whose filesystem cleanup
// failed. PostgreSQL deletion owns the atomic installation and KV transaction.
func (s *Service) Uninstall(ctx context.Context, id string, purge bool) error {
@@ -286,6 +289,13 @@ func (s *Service) Uninstall(ctx context.Context, id string, purge bool) error {
if err := s.manager.store.Delete(ctx, id, purge); err != nil {
return err
}
+ if purge && s.manager.data != nil {
+ // Host KV is already committed; preserve any failed filesystem cleanup
+ // for a later purge retry before removing the remaining package state.
+ if err := s.manager.data.Remove(ctx, id); err != nil {
+ return err
+ }
+ }
return s.manager.syncLocked(ctx, id)
})
}
diff --git a/backend/tests/integration/plugins/catalog_scenario_integration_test.go b/backend/tests/integration/plugins/catalog_scenario_integration_test.go
index a87c9c33f..31cec05aa 100644
--- a/backend/tests/integration/plugins/catalog_scenario_integration_test.go
+++ b/backend/tests/integration/plugins/catalog_scenario_integration_test.go
@@ -117,7 +117,7 @@ func TestSignedCatalogInstallationAndFailedUpdate(t *testing.T) {
require.NoError(t, err)
configuration := plugin.NewConfiguration(cipher, true)
processes := provider.NewProcesses(plugin.RuntimeOptions{HostVersion: "0.6.0", InstanceID: "catalog-test", Timezone: "UTC"}, logger, nil)
- manager := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.6.0", StateRoot: filepath.Join(directory, "state")}, store, packages, processes, configuration, logger)
+ manager := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.6.0", StateRoot: filepath.Join(directory, "state")}, store, packages, processes, configuration, nil, logger)
catalog := plugin.NewCatalogService(plugin.CatalogOptions{Enabled: true, Host: manifest.HostInfo{Version: "0.6.0", APIVersions: []int{1}, Platform: platform, Permissions: manifest.Permissions()}}, store, store, fetcher, logger)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
diff --git a/backend/tests/integration/plugins/installation_scenario_integration_test.go b/backend/tests/integration/plugins/installation_scenario_integration_test.go
index 2e34667d8..99a380c74 100644
--- a/backend/tests/integration/plugins/installation_scenario_integration_test.go
+++ b/backend/tests/integration/plugins/installation_scenario_integration_test.go
@@ -55,7 +55,7 @@ func TestInstalledPluginHTTPPersistenceAndRecovery(t *testing.T) {
packages, err := provider.NewPackageFiles(directory, provider.DownloadOptions{})
require.NoError(t, err)
processes := provider.NewProcesses(plugin.RuntimeOptions{HostVersion: "0.5.4", InstanceID: "installation-test", Timezone: "UTC"}, logger, nil)
- manager := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.5.4", StateRoot: filepath.Join(directory, "state")}, store, packages, processes, configuration, logger)
+ manager := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.5.4", StateRoot: filepath.Join(directory, "state")}, store, packages, processes, configuration, nil, logger)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -148,7 +148,7 @@ func TestInstalledPluginHTTPPersistenceAndRecovery(t *testing.T) {
// Stop the first owner before reconstructing the same durable installation; the second manager must restore enabled intent without an HTTP enable request.
require.NoError(t, manager.Stop(t.Context()))
restartedProcesses := provider.NewProcesses(plugin.RuntimeOptions{HostVersion: "0.5.4", InstanceID: "restarted-test", Timezone: "UTC"}, logger, nil)
- restarted := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.5.4", StateRoot: filepath.Join(directory, "state")}, store, packages, restartedProcesses, configuration, logger)
+ restarted := plugin.NewManager(plugin.ManagerOptions{Enabled: true, MaxRunning: 20, HostVersion: "0.5.4", StateRoot: filepath.Join(directory, "state")}, store, packages, restartedProcesses, configuration, nil, logger)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
index 853c16943..5b0ff7fce 100644
--- a/docs/architecture/index.md
+++ b/docs/architecture/index.md
@@ -8,7 +8,7 @@
## 文档
-- [插件系统](plugin_system.md):单实例插件安装、签名索引、版本恢复、配置密钥、进程、租约、回调、写入台账、只读权限、开发目录及信任边界。读取时机:修改插件安装管理、索引市场、更新回滚、配置、启停、回调权限或开发模式时读取。
+- [插件系统](plugin_system.md):单实例插件安装、签名索引、版本恢复、配置密钥、持久目录、进程、租约、回调、写入台账、只读权限、开发目录及信任边界。读取时机:修改插件安装管理、索引市场、更新回滚、配置、启停、回调权限、本地数据或开发模式时读取。
- [后端模块地图](backend_modules.md):后端目录、每个包的职责、静态依赖和主要模块的协作图。读取时机:查找后端代码、判断包的归属、修改模块接口或依赖时读取。
- [前端模块与依赖](frontend_modules.md):页面私有模块、共享组件、API 类型归属、依赖检查和启动约束。读取时机:组织前端目录、移动类型或组件、修改路由装配和跨页面依赖时读取。
diff --git a/docs/architecture/plugin_system.md b/docs/architecture/plugin_system.md
index 5826292af..59290aad3 100644
--- a/docs/architecture/plugin_system.md
+++ b/docs/architecture/plugin_system.md
@@ -1,6 +1,6 @@
# 插件系统
-本文说明插件安装与版本管理、进程启停、请求租约、宿主能力和前端资源的运行边界。插件声明、安装包和 SDK 协议见[插件契约](../interfaces/plugin_contracts.md),部署、备份和恢复步骤见[插件运维](../operations/plugin_operations.md)。
+本文说明插件安装与版本管理、进程启停、请求租约、持久业务文件、宿主能力和前端资源的运行边界。插件声明、安装包和 SDK 协议见[插件契约](../interfaces/plugin_contracts.md),部署、备份和恢复步骤见[插件运维](../operations/plugin_operations.md)。
## 章节导航
@@ -16,6 +16,7 @@
- [只读能力](#read_capabilities):查询范围与字段限制。
- [KV 存储](#kv_storage):条件写、配额和过期清理。
- [定时任务](#task_scheduling):调度、取消与最近执行结果。
+- [持久业务文件](#persistent_data):默认目录、保留清除与离线备份。
- [开发目录](#development_mode):本地开发覆盖与持久记录。
@@ -80,7 +81,7 @@ SDK 控件复用宿主原生实现;对话框的 Teleport 面板携带插件样
`installed/