From faa74b7a927e87974e8c9d15b86b608efe89498b Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 20 Oct 2026 11:41:12 +0000 Subject: [PATCH] fix(addons): refuse a reinstall that would migrate retained data unbacked An upgrade takes a verified backup before the new package migrates the data. A reinstall over retained data has no running generation to stop, so the new package would migrate it without one. Refuse a reinstall whose data format differs from the retained one. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto --- core/services/addons/manager.go | 8 ++++++++ core/services/addons/recovery/migration.go | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/core/services/addons/manager.go b/core/services/addons/manager.go index c414c9398..954d99e63 100644 --- a/core/services/addons/manager.go +++ b/core/services/addons/manager.go @@ -364,6 +364,14 @@ func (m *Manager) Submit(ctx context.Context, actor contract.Actor, cmd Lifecycl if last, ok := lastReceipt(m.journal.Addons[cmd.AddonID]); ok && recovery.CheckCompatible(last, staged.Receipt()) != nil { return fail() } + if cmd.Action != "upgrade" { + // A reinstall has no running generation to back up, and + // the candidate would migrate the retained data when it + // starts. Only an upgrade takes a verified backup first. + if last, ok := lastReceipt(m.journal.Addons[cmd.AddonID]); ok && recovery.FormatChanged(last, staged.Receipt()) { + return fail() + } + } if cmd.Action == "upgrade" { return m.upgradeWithRecovery(ctx, cmd, op, &next, staged, fail) } diff --git a/core/services/addons/recovery/migration.go b/core/services/addons/recovery/migration.go index 53e3a2ab3..ee3aa8c26 100644 --- a/core/services/addons/recovery/migration.go +++ b/core/services/addons/recovery/migration.go @@ -35,6 +35,13 @@ func formatChanged(prev, next packages.PackageReceipt) bool { return next.Manifest.DataFormat.Max != prev.Manifest.DataFormat.Max } +// FormatChanged reports whether next would migrate the data that prev wrote. +// A caller that cannot take a backup first (a reinstall has no running +// generation to stop) must refuse such a package rather than migrate unbacked. +func FormatChanged(prev, next packages.PackageReceipt) bool { + return prev.Manifest.ID != "" && formatChanged(prev, next) +} + // migrationState reads the status file the add-on writes while it migrates. // The bool is false when it wrote none. func (r *Recovery) migrationState(addonID string) (string, string, bool) {