From 70a2f39042ffd69256e1407d43e8512de80a541e Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 30 Aug 2026 21:34:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=85=8D=E7=BD=AE=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E9=A2=84=E6=A3=80=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=A4=B1=E8=B4=A5=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/config/database_migration.go | 3 ++ server/config/manager_v2.go | 17 ++++++- server/config/manager_v2_test.go | 51 +++++++++++++++++++ server/config/snapshot.go | 9 +++- server/config/snapshot_test.go | 22 ++++++++ server/services/dbmigration/sql_executor.go | 12 +++++ .../services/dbmigration/sql_executor_test.go | 14 +++++ .../services/dbmigration/sqlite_executor.go | 4 ++ .../dbmigration/sqlite_executor_test.go | 17 +++++++ 9 files changed, 147 insertions(+), 2 deletions(-) diff --git a/server/config/database_migration.go b/server/config/database_migration.go index f19935d..a41b50e 100644 --- a/server/config/database_migration.go +++ b/server/config/database_migration.go @@ -59,6 +59,9 @@ func (m *ConfigManager) SwitchDatabase(ctx context.Context, expectedGeneration u if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil { return err } + if err := m.snapshots.Validate(candidate); err != nil { + return err + } if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil { return err } diff --git a/server/config/manager_v2.go b/server/config/manager_v2.go index 86a55cb..86516a7 100644 --- a/server/config/manager_v2.go +++ b/server/config/manager_v2.go @@ -108,11 +108,17 @@ type LegacyPluginSecretImporter func(context.Context, []LegacyPluginSecret) erro var ErrPluginSecretMigrationRequired = errors.New("plugin secret migration is required") +type runtimeSnapshotStore interface { + Validate(RuntimeConfig) error + Publish(RuntimeConfig) error + Snapshot() (RuntimeConfig, error) +} + type ConfigManager struct { mu sync.RWMutex writeMu sync.Mutex loader *ConfigLoader - snapshots *SnapshotStore + snapshots runtimeSnapshotStore current RuntimeConfig pending *pendingConfig status ConfigStatus @@ -349,6 +355,9 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi if err := ValidateRuntimeSecrets(m.vault, candidate); err != nil { return nil, err } + if err := m.snapshots.Validate(candidate); err != nil { + return nil, err + } if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil { return nil, err } @@ -523,6 +532,9 @@ func (m *ConfigManager) rollback(ctx context.Context, generation uint64, actor A if err := ValidateRuntimeConfig(candidate, m.options); err != nil { return nil, err } + if err := m.snapshots.Validate(candidate); err != nil { + return nil, err + } if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil { return nil, err } @@ -831,6 +843,9 @@ func (m *ConfigManager) MigrateLegacyPluginSecrets(ctx context.Context, importer if err := ValidateRuntimeConfig(candidate, m.options); err != nil { return false, err } + if err := m.snapshots.Validate(candidate); err != nil { + return false, err + } if _, _, err := m.transactions.CreateBackupIfDue(current, time.Now().UTC()); err != nil { return false, err } diff --git a/server/config/manager_v2_test.go b/server/config/manager_v2_test.go index 816da3b..7a75d77 100644 --- a/server/config/manager_v2_test.go +++ b/server/config/manager_v2_test.go @@ -56,6 +56,23 @@ func authorizedConfigActor() AuditActor { return AuditActor{UserID: "1", Role: "super_admin", SessionID: "test-session", RecentAuth: true, RecoveryCodeConfigured: true} } +type rejectingRuntimeSnapshotStore struct { + current RuntimeConfig + err error +} + +func (s *rejectingRuntimeSnapshotStore) Validate(RuntimeConfig) error { + return s.err +} + +func (s *rejectingRuntimeSnapshotStore) Publish(RuntimeConfig) error { + return s.err +} + +func (s *rejectingRuntimeSnapshotStore) Snapshot() (RuntimeConfig, error) { + return cloneRuntimeConfig(s.current), nil +} + func TestConfigLoaderLoadsFixedPartitionsAndRejectsDigestMismatch(t *testing.T) { root := t.TempDir() writeRuntimeFixture(t, root, validRuntimeConfig()) @@ -122,6 +139,40 @@ func TestConfigLoaderRejectsMissingPartitionAndGenerationMismatch(t *testing.T) } } +func TestConfigManagerApplyPreflightsSnapshotBeforePersisting(t *testing.T) { + root := t.TempDir() + writeRuntimeFixture(t, root, validRuntimeConfig()) + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + current := manager.Snapshot() + manager.snapshots = &rejectingRuntimeSnapshotStore{current: current, err: errors.New("snapshot validation rejected")} + email := current.Email + email.Policy.MaxMessageBytes = 8 << 20 + payload, err := json.Marshal(email) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: SectionEmail, ExpectedGeneration: current.Generation, Payload: payload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Apply(context.Background(), updated.Generation); !errors.Is(err, manager.snapshots.(*rejectingRuntimeSnapshotStore).err) { + t.Fatalf("apply error = %v, want snapshot validation error", err) + } + loaded, err := NewConfigLoader(root, options).Load() + if err != nil { + t.Fatal(err) + } + if loaded.Generation != current.Generation || loaded.Email.Policy.MaxMessageBytes == 8<<20 { + t.Fatalf("candidate was persisted before snapshot validation: %+v", loaded) + } +} + func TestConfigManagerUpdateApplyUsesPendingGeneration(t *testing.T) { root := t.TempDir() writeRuntimeFixture(t, root, validRuntimeConfig()) diff --git a/server/config/snapshot.go b/server/config/snapshot.go index e2314ae..b9c4e8e 100644 --- a/server/config/snapshot.go +++ b/server/config/snapshot.go @@ -20,12 +20,19 @@ func NewSnapshotStore(initial RuntimeConfig, options RuntimeValidationOptions) ( return store, nil } +func (s *SnapshotStore) Validate(candidate RuntimeConfig) error { + if s == nil { + return ErrRuntimeSnapshotUnavailable + } + return ValidateRuntimeConfig(candidate, s.options) +} + func (s *SnapshotStore) Publish(candidate RuntimeConfig) error { if s == nil { return ErrRuntimeSnapshotUnavailable } candidate = cloneRuntimeConfig(candidate) - if err := ValidateRuntimeConfig(candidate, s.options); err != nil { + if err := s.Validate(candidate); err != nil { return err } for { diff --git a/server/config/snapshot_test.go b/server/config/snapshot_test.go index 787698f..d52a0ec 100644 --- a/server/config/snapshot_test.go +++ b/server/config/snapshot_test.go @@ -24,6 +24,28 @@ func TestSnapshotStoreRejectsInvalidCandidate(t *testing.T) { } } +func TestSnapshotStoreValidateDoesNotPublishCandidate(t *testing.T) { + initial := validRuntimeConfig() + store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}) + if err != nil { + t.Fatal(err) + } + candidate := validRuntimeConfig() + candidate.Generation = 2 + candidate.Root.Generation = 2 + candidate.Root.Sections = validSectionReferences(2) + candidate.SMTP.Meta.Generation = 2 + candidate.Email.Meta.Generation = 2 + candidate.SSL.Meta.Generation = 2 + candidate.Plugins.Meta.Generation = 2 + if err := store.Validate(candidate); err != nil { + t.Fatal(err) + } + if got := store.Generation(); got != initial.Generation { + t.Fatalf("validation changed active generation: got %d, want %d", got, initial.Generation) + } +} + func TestSnapshotStoreReturnsDeepCopies(t *testing.T) { initial := validRuntimeConfig() store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}) diff --git a/server/services/dbmigration/sql_executor.go b/server/services/dbmigration/sql_executor.go index 8b683c6..5e3f547 100644 --- a/server/services/dbmigration/sql_executor.go +++ b/server/services/dbmigration/sql_executor.go @@ -126,6 +126,10 @@ func (e *SQLExecutor) Run(ctx context.Context, plan Plan, update func(Status) er return ErrSwitchUnavailable } if err := e.authorizedSwitch(ctx, plan.expectedGeneration, plan.targetConfig, plan.actor); err != nil { + if isKnownSwitchRejection(err) { + cleanupStructuredTarget(e.copier, plan.sourceConfig, plan.targetConfig) + return err + } return errors.Join(ErrSwitchOutcomeUnknown, err) } if cleanup, ok := e.copier.(StructuredDatabaseCleanup); ok { @@ -134,6 +138,14 @@ func (e *SQLExecutor) Run(ctx context.Context, plan Plan, update func(Status) er return nil } +func isKnownSwitchRejection(err error) bool { + return errors.Is(err, config.ErrAuthorizationRequired) || + errors.Is(err, config.ErrDatabaseMigrationTarget) || + errors.Is(err, config.ErrGenerationConflict) || + errors.Is(err, config.ErrStepUpRequired) || + errors.Is(err, config.ErrInvalidRuntimeConfig) +} + func cleanupStructuredTarget(copier StructuredDatabaseCopier, source, target config.DatabaseConfig) { cleanup, ok := copier.(StructuredDatabaseCleanup) if !ok { diff --git a/server/services/dbmigration/sql_executor_test.go b/server/services/dbmigration/sql_executor_test.go index c35840e..337298d 100644 --- a/server/services/dbmigration/sql_executor_test.go +++ b/server/services/dbmigration/sql_executor_test.go @@ -190,6 +190,20 @@ func (c *cleanupRecordingCopier) Complete(config.DatabaseConfig, config.Database c.completeCalled = true } +func TestSQLExecutorCleansTargetForKnownConfigurationSwitchRejection(t *testing.T) { + copier := &cleanupRecordingCopier{} + executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error { + return config.ErrGenerationConflict + }) + err := executor.Run(context.Background(), validStructuredPlan(t), func(Status) error { return nil }) + if !errors.Is(err, config.ErrGenerationConflict) || errors.Is(err, ErrSwitchOutcomeUnknown) { + t.Fatalf("known switch rejection = %v", err) + } + if !copier.cleanupCalled { + t.Fatal("structured target was not cleaned after known switch rejection") + } +} + func TestSQLExecutorPreservesStructuredTargetWhenConfigurationSwitchOutcomeIsUnknown(t *testing.T) { copier := &cleanupRecordingCopier{} executor := NewSQLExecutor(copier, func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error { diff --git a/server/services/dbmigration/sqlite_executor.go b/server/services/dbmigration/sqlite_executor.go index d445a77..9208071 100644 --- a/server/services/dbmigration/sqlite_executor.go +++ b/server/services/dbmigration/sqlite_executor.go @@ -134,6 +134,10 @@ func (e *SQLiteExecutor) Run(ctx context.Context, plan Plan, update func(Status) switchErr = e.switchConfig(ctx, plan.targetConfig) } if switchErr != nil { + if isKnownSwitchRejection(switchErr) { + _ = os.Remove(targetPath) + return switchErr + } return errors.Join(ErrSwitchOutcomeUnknown, switchErr) } switched = true diff --git a/server/services/dbmigration/sqlite_executor_test.go b/server/services/dbmigration/sqlite_executor_test.go index 79ea959..27baf5b 100644 --- a/server/services/dbmigration/sqlite_executor_test.go +++ b/server/services/dbmigration/sqlite_executor_test.go @@ -139,6 +139,23 @@ func TestSQLiteExecutorRequiresConfigurationSwitch(t *testing.T) { } } +func TestSQLiteExecutorCleansTargetForKnownConfigurationSwitchRejection(t *testing.T) { + service, root := newSQLiteMigrationFixture(t, func(context.Context, config.DatabaseConfig) error { + return config.ErrGenerationConflict + }) + record, token, err := service.Preview(context.Background(), config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"}, 1, "known-rejection.db") + if err != nil { + t.Fatal(err) + } + failed, err := service.Execute(context.Background(), record.ID, token) + if !errors.Is(err, config.ErrGenerationConflict) || errors.Is(err, ErrSwitchOutcomeUnknown) { + t.Fatalf("known switch rejection = %v, record = %+v", err, failed) + } + if _, statErr := os.Stat(filepath.Join(root, "config", "known-rejection.db")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("target database remained after known switch rejection, stat error = %v", statErr) + } +} + func TestSQLiteExecutorPreservesTargetWhenConfigurationSwitchOutcomeIsUnknown(t *testing.T) { service, root := newSQLiteMigrationFixture(t, func(context.Context, config.DatabaseConfig) error { return errors.New("配置切换失败")