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/