From da0f51ce5f6eae6416117c829a062b7b8bcae9c5 Mon Sep 17 00:00:00 2001 From: Atanas Janeshliev Date: Tue, 21 Jan 2025 12:34:04 +0100 Subject: [PATCH] feat(BRIDGE-309): Update to the bridge updater logic corresponding to the version file restructure --- internal/bridge/bridge.go | 58 +- internal/bridge/bridge_test.go | 47 +- internal/bridge/mocks.go | 33 +- internal/bridge/types.go | 6 +- internal/bridge/updates.go | 266 ++++++- internal/bridge/updates_test.go | 700 ++++++++++++++++++ internal/events/update.go | 107 ++- internal/frontend/cli/frontend.go | 8 +- internal/frontend/grpc/service.go | 43 +- internal/frontend/grpc/service_methods.go | 24 +- internal/unleash/service.go | 7 +- internal/updater/types_test.go | 255 +++++++ internal/updater/types_version.go | 135 ++++ internal/updater/updater.go | 101 ++- internal/updater/version.go | 34 +- internal/updater/version_test.go | 205 +++++ .../updater/versioncompare/compare_darwin.go | 134 ++++ .../versioncompare/compare_darwin_test.go | 105 +++ .../updater/versioncompare/compare_linux.go | 31 + .../updater/versioncompare/compare_windows.go | 31 + internal/updater/versioncompare/types.go | 29 + tests/api_test.go | 6 + tests/bridge_test.go | 37 +- ...updates.feature => updates_legacy.feature} | 11 + tests/steps_test.go | 3 + utils/versioner/main.go | 2 +- 26 files changed, 2291 insertions(+), 127 deletions(-) create mode 100644 internal/bridge/updates_test.go create mode 100644 internal/updater/types_test.go create mode 100644 internal/updater/types_version.go create mode 100644 internal/updater/version_test.go create mode 100644 internal/updater/versioncompare/compare_darwin.go create mode 100644 internal/updater/versioncompare/compare_darwin_test.go create mode 100644 internal/updater/versioncompare/compare_linux.go create mode 100644 internal/updater/versioncompare/compare_windows.go create mode 100644 internal/updater/versioncompare/types.go rename tests/features/bridge/{updates.feature => updates_legacy.feature} (77%) diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index 7e1f6387..72cb7b2e 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -55,6 +55,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/bradenaw/juniper/xslices" + "github.com/elastic/go-sysinfo/types" "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" ) @@ -81,8 +82,9 @@ type Bridge struct { imapEventCh chan imapEvents.Event // updater is the bridge's updater. - updater Updater - installCh chan installJob + updater Updater + installChLegacy chan installJobLegacy + installCh chan installJob // heartbeat is the telemetry heartbeat for metrics. heartbeat *heartBeatState @@ -149,6 +151,9 @@ type Bridge struct { // notificationStore is used for notification deduplication notificationStore *notifications.Store + + // getHostVersion primarily used for testing the update logic - it should return an OS version + getHostVersion func(host types.Host) string } var logPkg = logrus.WithField("pkg", "bridge") //nolint:gochecknoglobals @@ -283,8 +288,9 @@ func newBridge( tlsConfig: tlsConfig, imapEventCh: imapEventCh, - updater: updater, - installCh: make(chan installJob), + updater: updater, + installChLegacy: make(chan installJobLegacy), + installCh: make(chan installJob), curVersion: curVersion, newVersion: curVersion, @@ -316,6 +322,8 @@ func newBridge( observabilityService: observabilityService, notificationStore: notifications.NewStore(locator.ProvideNotificationsCachePath), + + getHostVersion: func(host types.Host) string { return host.Info().OS.Version }, } bridge.serverManager = imapsmtpserver.NewService(context.Background(), @@ -436,8 +444,17 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error { // Check for updates when triggered. bridge.goUpdate = bridge.tasks.PeriodicOrTrigger(constants.UpdateCheckInterval, 0, func(ctx context.Context) { logPkg.Info("Checking for updates") + var versionLegacy updater.VersionInfoLegacy + var version updater.VersionInfo + var err error + + useOldUpdateLogic := bridge.GetFeatureFlagValue(unleash.UpdateUseNewVersionFileStructureDisabled) + if useOldUpdateLogic { + versionLegacy, err = bridge.updater.GetVersionInfoLegacy(ctx, bridge.api, bridge.vault.GetUpdateChannel()) + } else { + version, err = bridge.updater.GetVersionInfo(ctx, bridge.api) + } - version, err := bridge.updater.GetVersionInfo(ctx, bridge.api, bridge.vault.GetUpdateChannel()) if err != nil { bridge.publish(events.UpdateCheckFailed{Error: err}) if errors.Is(err, updater.ErrVersionFileDownloadOrVerify) { @@ -450,12 +467,23 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error { } } } else { - bridge.handleUpdate(version) + if useOldUpdateLogic { + bridge.handleUpdateLegacy(versionLegacy) + } else { + bridge.handleUpdate(version) + } } }) defer bridge.goUpdate() - // Install updates when available. + // Install updates when available - based on old update logic + bridge.tasks.Once(func(ctx context.Context) { + async.RangeContext(ctx, bridge.installChLegacy, func(job installJobLegacy) { + bridge.installUpdateLegacy(ctx, job) + }) + }) + + // Install updates when available - based on new update logic bridge.tasks.Once(func(ctx context.Context) { async.RangeContext(ctx, bridge.installCh, func(job installJob) { bridge.installUpdate(ctx, job) @@ -740,3 +768,19 @@ func (bridge *Bridge) ReportMessageWithContext(message string, messageCtx report func (bridge *Bridge) GetUsers() map[string]*user.User { return bridge.users } + +// SetCurrentVersionTest - sets the current version of bridge; should only be used for tests. +func (bridge *Bridge) SetCurrentVersionTest(version *semver.Version) { + bridge.curVersion = version + bridge.newVersion = version +} + +// SetHostVersionGetterTest - sets the OS version helper func; only used for testing. +func (bridge *Bridge) SetHostVersionGetterTest(fn func(host types.Host) string) { + bridge.getHostVersion = fn +} + +// SetRolloutPercentageTest - sets the rollout percentage; should only be used for testing. +func (bridge *Bridge) SetRolloutPercentageTest(rollout float64) error { + return bridge.vault.SetUpdateRollout(rollout) +} diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 9e698e8c..ff6f9b44 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -45,6 +45,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/focus" "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver" + "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/user" "github.com/ProtonMail/proton-bridge/v3/internal/useragent" @@ -383,9 +384,14 @@ func TestBridge_Cookies(t *testing.T) { }) } -func TestBridge_CheckUpdate(t *testing.T) { +func TestBridge_CheckUpdate_Legacy(t *testing.T) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { + unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0) + s.PushFeatureFlag(unleash.UpdateUseNewVersionFileStructureDisabled) + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { + // Wait for FF poll. + time.Sleep(600 * time.Millisecond) // Disable autoupdate for this test. require.NoError(t, bridge.SetAutoUpdate(false)) @@ -400,7 +406,7 @@ func TestBridge_CheckUpdate(t *testing.T) { require.Equal(t, events.UpdateNotAvailable{}, <-noUpdateCh) // Simulate a new version being available. - mocks.Updater.SetLatestVersion(v2_4_0, v2_3_0) + mocks.Updater.SetLatestVersionLegacy(v2_4_0, v2_3_0) // Get a stream of update available events. updateCh, done := bridge.GetEvents(events.UpdateAvailable{}) @@ -411,7 +417,7 @@ func TestBridge_CheckUpdate(t *testing.T) { // We should receive an event indicating that an update is available. require.Equal(t, events.UpdateAvailable{ - Version: updater.VersionInfo{ + VersionLegacy: updater.VersionInfoLegacy{ Version: v2_4_0, MinAuto: v2_3_0, RolloutProportion: 1.0, @@ -423,25 +429,30 @@ func TestBridge_CheckUpdate(t *testing.T) { }) } -func TestBridge_AutoUpdate(t *testing.T) { +func TestBridge_AutoUpdate_Legacy(t *testing.T) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { - withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { + unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0) + s.PushFeatureFlag(unleash.UpdateUseNewVersionFileStructureDisabled) + + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) { + // Wait for FF poll. + time.Sleep(600 * time.Millisecond) // Enable autoupdate for this test. - require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, b.SetAutoUpdate(true)) // Get a stream of update events. - updateCh, done := bridge.GetEvents(events.UpdateInstalled{}) + updateCh, done := b.GetEvents(events.UpdateInstalled{}) defer done() // Simulate a new version being available. - mocks.Updater.SetLatestVersion(v2_4_0, v2_3_0) + mocks.Updater.SetLatestVersionLegacy(v2_4_0, v2_3_0) // Check for updates. - bridge.CheckForUpdates() + b.CheckForUpdates() // We should receive an event indicating that the update was silently installed. require.Equal(t, events.UpdateInstalled{ - Version: updater.VersionInfo{ + VersionLegacy: updater.VersionInfoLegacy{ Version: v2_4_0, MinAuto: v2_3_0, RolloutProportion: 1.0, @@ -452,9 +463,14 @@ func TestBridge_AutoUpdate(t *testing.T) { }) } -func TestBridge_ManualUpdate(t *testing.T) { +func TestBridge_ManualUpdate_Legacy(t *testing.T) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { + unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0) + s.PushFeatureFlag(unleash.UpdateUseNewVersionFileStructureDisabled) + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { + // Wait for FF poll. + time.Sleep(600 * time.Millisecond) // Disable autoupdate for this test. require.NoError(t, bridge.SetAutoUpdate(false)) @@ -463,14 +479,14 @@ func TestBridge_ManualUpdate(t *testing.T) { defer done() // Simulate a new version being available, but it's too new for us. - mocks.Updater.SetLatestVersion(v2_4_0, v2_4_0) + mocks.Updater.SetLatestVersionLegacy(v2_4_0, v2_4_0) // Check for updates. bridge.CheckForUpdates() // We should receive an event indicating an update is available, but we can't install it. require.Equal(t, events.UpdateAvailable{ - Version: updater.VersionInfo{ + VersionLegacy: updater.VersionInfoLegacy{ Version: v2_4_0, MinAuto: v2_4_0, RolloutProportion: 1.0, @@ -484,7 +500,12 @@ func TestBridge_ManualUpdate(t *testing.T) { func TestBridge_ForceUpdate(t *testing.T) { withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) { + unleash.ModifyPollPeriodAndJitter(500*time.Millisecond, 0) + s.PushFeatureFlag(unleash.UpdateUseNewVersionFileStructureDisabled) + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) { + // Wait for FF poll. + time.Sleep(600 * time.Millisecond) // Get a stream of update events. updateCh, done := bridge.GetEvents(events.UpdateForced{}) defer done() diff --git a/internal/bridge/mocks.go b/internal/bridge/mocks.go index d8c74098..48c0748f 100644 --- a/internal/bridge/mocks.go +++ b/internal/bridge/mocks.go @@ -119,13 +119,14 @@ func (provider *TestLocationsProvider) UserCache() string { } type TestUpdater struct { - latest updater.VersionInfo - lock sync.RWMutex + latest updater.VersionInfoLegacy + releases updater.VersionInfo + lock sync.RWMutex } func NewTestUpdater(version, minAuto *semver.Version) *TestUpdater { return &TestUpdater{ - latest: updater.VersionInfo{ + latest: updater.VersionInfoLegacy{ Version: version, MinAuto: minAuto, @@ -134,11 +135,11 @@ func NewTestUpdater(version, minAuto *semver.Version) *TestUpdater { } } -func (testUpdater *TestUpdater) SetLatestVersion(version, minAuto *semver.Version) { +func (testUpdater *TestUpdater) SetLatestVersionLegacy(version, minAuto *semver.Version) { testUpdater.lock.Lock() defer testUpdater.lock.Unlock() - testUpdater.latest = updater.VersionInfo{ + testUpdater.latest = updater.VersionInfoLegacy{ Version: version, MinAuto: minAuto, @@ -146,17 +147,35 @@ func (testUpdater *TestUpdater) SetLatestVersion(version, minAuto *semver.Versio } } -func (testUpdater *TestUpdater) GetVersionInfo(_ context.Context, _ updater.Downloader, _ updater.Channel) (updater.VersionInfo, error) { +func (testUpdater *TestUpdater) GetVersionInfoLegacy(_ context.Context, _ updater.Downloader, _ updater.Channel) (updater.VersionInfoLegacy, error) { testUpdater.lock.RLock() defer testUpdater.lock.RUnlock() return testUpdater.latest, nil } -func (testUpdater *TestUpdater) InstallUpdate(_ context.Context, _ updater.Downloader, _ updater.VersionInfo) error { +func (testUpdater *TestUpdater) InstallUpdateLegacy(_ context.Context, _ updater.Downloader, _ updater.VersionInfoLegacy) error { return nil } func (testUpdater *TestUpdater) RemoveOldUpdates() error { return nil } + +func (testUpdater *TestUpdater) SetLatestVersion(releases updater.VersionInfo) { + testUpdater.lock.Lock() + defer testUpdater.lock.Unlock() + + testUpdater.releases = releases +} + +func (testUpdater *TestUpdater) GetVersionInfo(_ context.Context, _ updater.Downloader) (updater.VersionInfo, error) { + testUpdater.lock.RLock() + defer testUpdater.lock.RUnlock() + + return testUpdater.releases, nil +} + +func (testUpdater *TestUpdater) InstallUpdate(_ context.Context, _ updater.Downloader, _ updater.Release) error { + return nil +} diff --git a/internal/bridge/types.go b/internal/bridge/types.go index 56c679d2..7c92ff1c 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -52,7 +52,9 @@ type Autostarter interface { } type Updater interface { - GetVersionInfo(context.Context, updater.Downloader, updater.Channel) (updater.VersionInfo, error) - InstallUpdate(context.Context, updater.Downloader, updater.VersionInfo) error + GetVersionInfoLegacy(context.Context, updater.Downloader, updater.Channel) (updater.VersionInfoLegacy, error) + InstallUpdateLegacy(context.Context, updater.Downloader, updater.VersionInfoLegacy) error RemoveOldUpdates() error + GetVersionInfo(context.Context, updater.Downloader) (updater.VersionInfo, error) + InstallUpdate(context.Context, updater.Downloader, updater.Release) error } diff --git a/internal/bridge/updates.go b/internal/bridge/updates.go index d9fc41c1..4a21b3ab 100644 --- a/internal/bridge/updates.go +++ b/internal/bridge/updates.go @@ -21,22 +21,168 @@ import ( "context" "errors" + "github.com/Masterminds/semver/v3" "github.com/ProtonMail/gluon/reporter" "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/updater" + "github.com/elastic/go-sysinfo" "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" ) func (bridge *Bridge) CheckForUpdates() { bridge.goUpdate() } -func (bridge *Bridge) InstallUpdate(version updater.VersionInfo) { - bridge.installCh <- installJob{version: version, silent: false} +func (bridge *Bridge) InstallUpdateLegacy(version updater.VersionInfoLegacy) { + bridge.installChLegacy <- installJobLegacy{version: version, silent: false} +} + +func (bridge *Bridge) InstallUpdate(release updater.Release) { + bridge.installCh <- installJob{Release: release, Silent: false} } func (bridge *Bridge) handleUpdate(version updater.VersionInfo) { + updateChannel := bridge.vault.GetUpdateChannel() + updateRollout := bridge.vault.GetUpdateRollout() + autoUpdateEnabled := bridge.vault.GetAutoUpdate() + + checkSystemVersion := true + hostInfo, err := sysinfo.Host() + // If we're unable to get host system information we skip the update's minimum/maximum OS version checks + if err != nil { + checkSystemVersion = false + logrus.WithError(err).Error("Failed to obtain host system info while handling updates") + if reporterErr := bridge.reporter.ReportMessageWithContext( + "Failed to obtain host system info while handling updates", + reporter.Context{"error": err}, + ); reporterErr != nil { + logrus.WithError(reporterErr).Error("Failed to report update error") + } + } + + if len(version.Releases) > 0 { + // Update latest is only used to update the release notes and landing page URL + bridge.publish(events.UpdateLatest{Release: version.Releases[0]}) + } + + // minAutoUpdateEvent - used to determine the highest compatible update that satisfies the Minimum Bridge version + minAutoUpdateEvent := events.UpdateAvailable{ + Release: updater.Release{Version: &semver.Version{}}, + Compatible: false, + Silent: false, + } + + // We assume that the version file is always created in descending order + // where newer versions are prepended to the top of the releases + // The logic for checking update eligibility is as follows: + // 1. Check release channel. + // 2. Check whether release version is greater. + // 3. Check if rollout is larger. + // 4. Check OS Version restrictions (provided that restrictions are provided, and we can extract the OS version). + // 5. Check Minimum Compatible Bridge Version. + // 6. Check if an update package is provided. + // 7. Check auto-update. + for _, release := range version.Releases { + log := logrus.WithFields(logrus.Fields{ + "current": bridge.curVersion, + "channel": updateChannel, + "update_version": release.Version, + "update_channel": release.ReleaseCategory, + "update_min_auto": release.MinAuto, + "update_rollout": release.RolloutProportion, + "update_min_os_version": release.SystemVersion.Minimum, + "update_max_os_version": release.SystemVersion.Maximum, + }) + + log.Debug("Checking update release") + + if !release.ReleaseCategory.UpdateEligible(updateChannel) { + log.Debug("Update does not satisfy update channel requirement") + continue + } + + if !release.Version.GreaterThan(bridge.curVersion) { + log.Debug("Update version is not greater than current version") + continue + } + + if release.RolloutProportion < updateRollout { + log.Debug("Update has not been rolled out yet") + continue + } + + if checkSystemVersion { + shouldContinue, err := release.SystemVersion.IsHostVersionEligible(log, hostInfo, bridge.getHostVersion) + if err != nil && shouldContinue { + log.WithError(err).Error( + "Failed to verify host system version compatibility during release check." + + "Error is non-fatal continuing with checks", + ) + } else if err != nil { + log.WithError(err).Error("Failed to verify host system version compatibility during update check") + continue + } + + if !shouldContinue { + log.Debug("Host version does not satisfy system requirements for update") + continue + } + } + + if release.MinAuto != nil && bridge.curVersion.LessThan(release.MinAuto) { + log.Debug("Update is available but is incompatible with this Bridge version") + if release.Version.GreaterThan(minAutoUpdateEvent.Release.Version) { + minAutoUpdateEvent.Release = release + } + continue + } + + // Check if we have a provided installer package + if found := slices.IndexFunc(release.File, func(file updater.File) bool { + return file.Identifier == updater.PackageIdentifier + }); found == -1 { + log.Error("Update is available but does not contain update package") + + if reporterErr := bridge.reporter.ReportMessageWithContext( + "Available update does not contain update package", + reporter.Context{"update_version": release.Version}, + ); reporterErr != nil { + log.WithError(reporterErr).Error("Failed to report update error") + } + + continue + } + + if !autoUpdateEnabled { + log.Info("An update is available but auto-update is disabled") + bridge.publish(events.UpdateAvailable{ + Release: release, + Compatible: true, + Silent: false, + }) + return + } + + // If we've gotten to this point that means an automatic update is available and we should install it + safe.RLock(func() { + bridge.installCh <- installJob{Release: release, Silent: true} + }, bridge.newVersionLock) + + return + } + + // If there's a release with a minAuto requirement that we satisfy (alongside all other checks) + // then notify the user that a manual update is needed + if !minAutoUpdateEvent.Release.Version.Equal(&semver.Version{}) { + bridge.publish(minAutoUpdateEvent) + } + + bridge.publish(events.UpdateNotAvailable{}) +} + +func (bridge *Bridge) handleUpdateLegacy(version updater.VersionInfoLegacy) { log := logrus.WithFields(logrus.Fields{ "version": version.Version, "current": bridge.curVersion, @@ -44,7 +190,7 @@ func (bridge *Bridge) handleUpdate(version updater.VersionInfo) { }) bridge.publish(events.UpdateLatest{ - Version: version, + VersionLegacy: version, }) switch { @@ -62,33 +208,33 @@ func (bridge *Bridge) handleUpdate(version updater.VersionInfo) { log.Info("An update is available but is incompatible with this version") bridge.publish(events.UpdateAvailable{ - Version: version, - Compatible: false, - Silent: false, + VersionLegacy: version, + Compatible: false, + Silent: false, }) case !bridge.vault.GetAutoUpdate(): log.Info("An update is available but auto-update is disabled") bridge.publish(events.UpdateAvailable{ - Version: version, - Compatible: true, - Silent: false, + VersionLegacy: version, + Compatible: true, + Silent: false, }) default: safe.RLock(func() { - bridge.installCh <- installJob{version: version, silent: true} + bridge.installChLegacy <- installJobLegacy{version: version, silent: true} }, bridge.newVersionLock) } } -type installJob struct { - version updater.VersionInfo +type installJobLegacy struct { + version updater.VersionInfoLegacy silent bool } -func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) { +func (bridge *Bridge) installUpdateLegacy(ctx context.Context, job installJobLegacy) { safe.Lock(func() { log := logrus.WithFields(logrus.Fields{ "version": job.version.Version, @@ -103,17 +249,12 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) { log.WithField("silent", job.silent).Info("An update is available") bridge.publish(events.UpdateAvailable{ - Version: job.version, - Compatible: true, - Silent: job.silent, + VersionLegacy: job.version, + Compatible: true, + Silent: job.silent, }) - bridge.publish(events.UpdateInstalling{ - Version: job.version, - Silent: job.silent, - }) - - err := bridge.updater.InstallUpdate(ctx, bridge.api, job.version) + err := bridge.updater.InstallUpdateLegacy(ctx, bridge.api, job.version) switch { case errors.Is(err, updater.ErrDownloadVerify): @@ -134,8 +275,79 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) { log.WithError(err).Error("The update could not be installed") bridge.publish(events.UpdateFailed{ - Version: job.version, - Silent: job.silent, + VersionLegacy: job.version, + Silent: job.silent, + Error: err, + }) + + default: + log.Info("The update was installed successfully") + + bridge.publish(events.UpdateInstalled{ + VersionLegacy: job.version, + Silent: job.silent, + }) + + bridge.newVersion = job.version.Version + } + }, bridge.newVersionLock) +} + +type installJob struct { + Release updater.Release + Silent bool +} + +func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) { + safe.Lock(func() { + log := logrus.WithFields(logrus.Fields{ + "version": job.Release.Version, + "current": bridge.curVersion, + "channel": bridge.vault.GetUpdateChannel(), + }) + + if !job.Release.Version.GreaterThan(bridge.newVersion) { + return + } + + log.WithField("silent", job.Silent).Info("An update is available") + + bridge.publish(events.UpdateAvailable{ + Release: job.Release, + Compatible: true, + Silent: job.Silent, + }) + + err := bridge.updater.InstallUpdate(ctx, bridge.api, job.Release) + switch { + case errors.Is(err, updater.ErrReleaseUpdatePackageMissing): + log.WithError(err).Error("The update could not be installed but we will fail silently") + if reporterErr := bridge.reporter.ReportExceptionWithContext( + "Cannot download update, update package is missing", + reporter.Context{"error": err}, + ); reporterErr != nil { + log.WithError(reporterErr).Error("Failed to report update error") + } + case errors.Is(err, updater.ErrDownloadVerify): + // BRIDGE-207: if download or verification fails, we do not want to trigger a manual update. We report in the log and to Sentry + // and we fail silently. + log.WithError(err).Error("The update could not be installed, but we will fail silently") + if reporterErr := bridge.reporter.ReportMessageWithContext( + "Cannot download or verify update", + reporter.Context{"error": err}, + ); reporterErr != nil { + log.WithError(reporterErr).Error("Failed to report update error") + } + + case errors.Is(err, updater.ErrUpdateAlreadyInstalled): + log.Info("The update was already installed") + + case err != nil: + log.WithError(err).Error("The update could not be installed") + + bridge.publish(events.UpdateFailed{ + Release: job.Release, + Silent: job.Silent, Error: err, }) @@ -143,11 +355,11 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) { log.Info("The update was installed successfully") bridge.publish(events.UpdateInstalled{ - Version: job.version, - Silent: job.silent, + Release: job.Release, + Silent: job.Silent, }) - bridge.newVersion = job.version.Version + bridge.newVersion = job.Release.Version } }, bridge.newVersionLock) } diff --git a/internal/bridge/updates_test.go b/internal/bridge/updates_test.go new file mode 100644 index 00000000..51149b16 --- /dev/null +++ b/internal/bridge/updates_test.go @@ -0,0 +1,700 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package bridge_test + +import ( + "context" + "runtime" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/ProtonMail/go-proton-api" + "github.com/ProtonMail/go-proton-api/server" + bridgePkg "github.com/ProtonMail/proton-bridge/v3/internal/bridge" + "github.com/ProtonMail/proton-bridge/v3/internal/events" + "github.com/ProtonMail/proton-bridge/v3/internal/updater" + "github.com/ProtonMail/proton-bridge/v3/internal/updater/versioncompare" + "github.com/elastic/go-sysinfo/types" + "github.com/stretchr/testify/require" +) + +// NOTE: we always assume the highest version is always the first in the release json array + +func Test_Update_BetaEligible(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + updateCh, done := bridge.GetEvents(events.UpdateInstalled{}) + defer done() + + err := bridge.SetUpdateChannel(updater.EarlyChannel) + require.NoError(t, err) + + bridge.SetCurrentVersionTest(semver.MustParse("2.1.1")) + + expectedRelease := updater.Release{ + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.1.2"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + expectedRelease, + }} + + go func() { + time.Sleep(1 * time.Second) + mocks.Updater.SetLatestVersion(updaterData) + bridge.CheckForUpdates() + }() + + select { + case update := <-updateCh: + require.Equal(t, events.UpdateInstalled{ + Release: expectedRelease, + Silent: true, + }, update) + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for update") + } + }) + }) +} + +func Test_Update_Stable(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + updateCh, done := bridge.GetEvents(events.UpdateInstalled{}) + defer done() + + err := bridge.SetUpdateChannel(updater.StableChannel) + require.NoError(t, err) + + bridge.SetCurrentVersionTest(semver.MustParse("2.1.1")) + + expectedRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.1.3"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.1.4"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedRelease, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateInstalled{ + Release: expectedRelease, + Silent: true, + }, <-updateCh) + }) + }) +} + +func Test_Update_CurrentReleaseNewest(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}) + defer done() + + err := bridge.SetUpdateChannel(updater.StableChannel) + require.NoError(t, err) + + bridge.SetCurrentVersionTest(semver.MustParse("2.1.5")) + + expectedRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.1.3"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.1.4"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedRelease, + }} + + mocks.Updater.SetLatestVersion(updaterData) + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateNotAvailable{}, <-updateCh) + }) + }) +} + +func Test_Update_NotRolledOutYet(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetUpdateChannel(updater.EarlyChannel)) + bridge.SetCurrentVersionTest(semver.MustParse("2.0.0")) + require.NoError(t, bridge.SetRolloutPercentageTest(1.0)) + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.1.5"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 0.5, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.1.4"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 0.5, + MinAuto: &semver.Version{}, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}) + defer done() + + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateNotAvailable{}, <-updateCh) + }) + }) +} + +func Test_Update_CheckOSVersion_NoUpdate(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, bridge.SetUpdateChannel(updater.StableChannel)) + + currentBridgeVersion := semver.MustParse("2.1.5") + bridge.SetCurrentVersionTest(currentBridgeVersion) + + // Override the OS version check + bridge.SetHostVersionGetterTest(func(_ types.Host) string { + return "10.0.0" + }) + + updateNotAvailableCh, done := bridge.GetEvents(events.UpdateNotAvailable{}) + defer done() + + updateCh, updateChDone := bridge.GetEvents(events.UpdateInstalled{}) + defer updateChDone() + + expectedRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.4.0"), + SystemVersion: versioncompare.SystemVersion{ + Minimum: "12.0.0", + Maximum: "13.0.0", + }, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + expectedRelease, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.3.0"), + SystemVersion: versioncompare.SystemVersion{ + Minimum: "10.1.0", + Maximum: "11.5", + }, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + if runtime.GOOS == "darwin" { + require.Equal(t, events.UpdateNotAvailable{}, <-updateNotAvailableCh) + } else { + require.Equal(t, events.UpdateInstalled{ + Release: expectedRelease, + Silent: true, + }, <-updateCh) + } + }) + }) +} + +func Test_Update_CheckOSVersion_HasUpdate(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, bridge.SetUpdateChannel(updater.StableChannel)) + + updateCh, done := bridge.GetEvents(events.UpdateInstalled{}) + defer done() + + currentBridgeVersion := semver.MustParse("2.1.5") + bridge.SetCurrentVersionTest(currentBridgeVersion) + + // Override the OS version check + bridge.SetHostVersionGetterTest(func(_ types.Host) string { + return "10.0.0" + }) + + expectedUpdateRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.2.0"), + SystemVersion: versioncompare.SystemVersion{ + Minimum: "10.0.0", + Maximum: "10.1.12", + }, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + expectedUpdateReleaseWindowsLinux := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.4.0"), + SystemVersion: versioncompare.SystemVersion{ + Minimum: "12.0.0", + }, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + expectedUpdateReleaseWindowsLinux, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.3.0"), + SystemVersion: versioncompare.SystemVersion{ + Minimum: "11.0.0", + }, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedUpdateRelease, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.1.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + if runtime.GOOS == "darwin" { + require.Equal(t, events.UpdateInstalled{ + Release: expectedUpdateRelease, + Silent: true, + }, <-updateCh) + } else { + require.Equal(t, events.UpdateInstalled{ + Release: expectedUpdateReleaseWindowsLinux, + Silent: true, + }, <-updateCh) + } + }) + }) +} + +func Test_Update_UpdateFromMinVer_UpdateAvailable(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, bridge.SetUpdateChannel(updater.StableChannel)) + + currentBridgeVersion := semver.MustParse("2.1.5") + bridge.SetCurrentVersionTest(currentBridgeVersion) + + updateCh, done := bridge.GetEvents(events.UpdateInstalled{}) + defer done() + + expectedUpdateRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.2.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: currentBridgeVersion, + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.3.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.1"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.2.1"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.0"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedUpdateRelease, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateInstalled{ + Release: expectedUpdateRelease, + Silent: true, + }, <-updateCh) + }) + }) +} + +// Test_Update_UpdateFromMinVer_NoCompatibleVersionForceManual - +// if we have an update, but we don't satisfy minVersion, a manual update to the highest possible version should be performed. +func Test_Update_UpdateFromMinVer_NoCompatibleVersionForceManual(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, bridge.SetUpdateChannel(updater.StableChannel)) + + currentBridgeVersion := semver.MustParse("2.1.5") + bridge.SetCurrentVersionTest(currentBridgeVersion) + + updateCh, done := bridge.GetEvents(events.UpdateAvailable{}) + defer done() + + expectedUpdateRelease := updater.Release{ + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.3.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.1"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.2.1"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.0"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + { + ReleaseCategory: updater.StableReleaseCategory, + Version: semver.MustParse("2.2.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.1.6"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedUpdateRelease, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateAvailable{ + Release: expectedUpdateRelease, + Silent: false, + Compatible: false, + }, <-updateCh) + }) + }) +} + +// Test_Update_UpdateFromMinVer_NoCompatibleVersionForceManual_BetaMismatch - only Beta updates are available +// nor do we satisfy the minVersion, we can't do anything in this case. +func Test_Update_UpdateFromMinVer_NoCompatibleVersionForceManual_BetaMismatch(t *testing.T) { + withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridgePkg.Locator, vaultKey []byte) { + withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridgePkg.Bridge, mocks *bridgePkg.Mocks) { + require.NoError(t, bridge.SetAutoUpdate(true)) + require.NoError(t, bridge.SetUpdateChannel(updater.StableChannel)) + + currentBridgeVersion := semver.MustParse("2.1.5") + bridge.SetCurrentVersionTest(currentBridgeVersion) + + updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}) + defer done() + + expectedUpdateRelease := updater.Release{ + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.3.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.1"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + } + + updaterData := updater.VersionInfo{Releases: []updater.Release{ + { + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.2.1"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.2.0"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + { + ReleaseCategory: updater.EarlyAccessReleaseCategory, + Version: semver.MustParse("2.2.0"), + SystemVersion: versioncompare.SystemVersion{}, + RolloutProportion: 1.0, + MinAuto: semver.MustParse("2.1.6"), + File: []updater.File{ + { + URL: "RANDOM_INSTALLER_URL", + Identifier: updater.InstallerIdentifier, + }, + { + URL: "RANDOM_PACKAGE_URL", + Identifier: updater.PackageIdentifier, + }, + }, + }, + expectedUpdateRelease, + }} + + mocks.Updater.SetLatestVersion(updaterData) + + bridge.CheckForUpdates() + + require.Equal(t, events.UpdateNotAvailable{}, <-updateCh) + }) + }) +} diff --git a/internal/events/update.go b/internal/events/update.go index 5f884ad6..8cf2d357 100644 --- a/internal/events/update.go +++ b/internal/events/update.go @@ -24,14 +24,35 @@ import ( ) // UpdateLatest is published when the latest version of bridge is known. +// It is only used for updating the release notes and landing page URLs. type UpdateLatest struct { eventBase - Version updater.VersionInfo + // VersionLegacy - holds Update version information; corresponding to the old update structure and logic; + VersionLegacy updater.VersionInfoLegacy + + // Release - holds Release version data; part of the new update logic as of BRIDGE-309. + Release updater.Release +} + +func (event UpdateLatest) GetLatestVersion() string { + var latestVersion string + if !event.VersionLegacy.IsEmpty() { + latestVersion = event.VersionLegacy.Version.String() + } else if !event.Release.IsEmpty() { + latestVersion = event.Release.Version.String() + } + return latestVersion } func (event UpdateLatest) String() string { - return fmt.Sprintf("UpdateLatest: Version: %s", event.Version.Version) + if !event.VersionLegacy.IsEmpty() { + return fmt.Sprintf("UpdateLatest: Version: %s", event.VersionLegacy.Version) + } + if !event.Release.IsEmpty() { + return fmt.Sprintf("UpdateLatest: Version: %s", event.Release.Version) + } + return "" } // UpdateAvailable is published when an update is available. @@ -40,7 +61,11 @@ func (event UpdateLatest) String() string { type UpdateAvailable struct { eventBase - Version updater.VersionInfo + // VersionLegacy - holds Update version information; corresponding to the old update structure and logic; + VersionLegacy updater.VersionInfoLegacy + + // Release - holds Release version data; part of the new update logic as of BRIDGE-309. + Release updater.Release // Compatible is true if the update can be installed automatically. Compatible bool @@ -49,8 +74,23 @@ type UpdateAvailable struct { Silent bool } +func (event UpdateAvailable) GetLatestVersion() string { + var latestVersion string + if !event.VersionLegacy.IsEmpty() { + latestVersion = event.VersionLegacy.Version.String() + } else if !event.Release.IsEmpty() { + latestVersion = event.Release.Version.String() + } + return latestVersion +} + func (event UpdateAvailable) String() string { - return fmt.Sprintf("UpdateAvailable: Version %s, Compatible: %t, Silent: %t", event.Version.Version, event.Compatible, event.Silent) + if !event.Release.IsEmpty() { + return fmt.Sprintf("UpdateAvailable: Version %s, Compatible: %t, Silent: %t", event.Release.Version, event.Compatible, event.Silent) + } else if !event.VersionLegacy.IsEmpty() { + return fmt.Sprintf("UpdateAvailable: Version %s, Compatible: %t, Silent: %t", event.VersionLegacy.Version, event.Compatible, event.Silent) + } + return "" } // UpdateNotAvailable is published when no update is available. @@ -62,45 +102,70 @@ func (event UpdateNotAvailable) String() string { return "UpdateNotAvailable" } -// UpdateInstalling is published when bridge begins installing an update. -type UpdateInstalling struct { - eventBase - - Version updater.VersionInfo - - Silent bool -} - -func (event UpdateInstalling) String() string { - return fmt.Sprintf("UpdateInstalling: Version %s, Silent: %t", event.Version.Version, event.Silent) -} - // UpdateInstalled is published when an update has been installed. type UpdateInstalled struct { eventBase - Version updater.VersionInfo + // VersionLegacy - holds Update version information; corresponding to the old update structure and logic; + VersionLegacy updater.VersionInfoLegacy + + // Release - holds Release version data; part of the new update logic as of BRIDGE-309. + Release updater.Release Silent bool } +func (event UpdateInstalled) GetLatestVersion() string { + var latestVersion string + if !event.VersionLegacy.IsEmpty() { + latestVersion = event.VersionLegacy.Version.String() + } else if !event.Release.IsEmpty() { + latestVersion = event.Release.Version.String() + } + return latestVersion +} + func (event UpdateInstalled) String() string { - return fmt.Sprintf("UpdateInstalled: Version %s, Silent: %t", event.Version.Version, event.Silent) + if !event.Release.IsEmpty() { + return fmt.Sprintf("UpdateInstalled: Version %s, Silent: %t", event.Release.Version, event.Silent) + } else if !event.VersionLegacy.IsEmpty() { + return fmt.Sprintf("UpdateInstalled: Version %s, Silent: %t", event.VersionLegacy.Version, event.Silent) + } + return "" } // UpdateFailed is published when an update fails to be installed. type UpdateFailed struct { eventBase - Version updater.VersionInfo + // VersionLegacy - holds Update version information; corresponding to the old update structure and logic; + VersionLegacy updater.VersionInfoLegacy + + // Release - holds Release version data; part of the new update logic as of BRIDGE-309. + Release updater.Release Silent bool Error error } +func (event UpdateFailed) GetLatestVersion() string { + var latestVersion string + if !event.VersionLegacy.IsEmpty() { + latestVersion = event.VersionLegacy.Version.String() + } else if !event.Release.IsEmpty() { + latestVersion = event.Release.Version.String() + } + return latestVersion +} + func (event UpdateFailed) String() string { - return fmt.Sprintf("UpdateFailed: Version %s, Silent: %t, Error: %s", event.Version.Version, event.Silent, event.Error) + if !event.Release.IsEmpty() { + return fmt.Sprintf("UpdateFailed: Version %s, Silent: %t, Error: %s", event.Release.Version, event.Silent, event.Error) + } else if !event.VersionLegacy.IsEmpty() { + return fmt.Sprintf("UpdateFailed: Version %s, Silent: %t, Error: %s", event.VersionLegacy.Version, event.Silent, event.Error) + } + return "" } // UpdateForced is published when the bridge version is too old and must be updated. diff --git a/internal/frontend/cli/frontend.go b/internal/frontend/cli/frontend.go index 2dd465c0..c8913d14 100644 --- a/internal/frontend/cli/frontend.go +++ b/internal/frontend/cli/frontend.go @@ -482,16 +482,16 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:gocyc case events.UpdateAvailable: if !event.Compatible { - f.Printf("A new version (%v) is available but it cannot be installed automatically.\n", event.Version.Version) + f.Printf("A new version (%v) is available but it cannot be installed automatically.\n", event.GetLatestVersion()) } else if !event.Silent { - f.Printf("A new version (%v) is available.\n", event.Version.Version) + f.Printf("A new version (%v) is available.\n", event.GetLatestVersion()) } case events.UpdateInstalled: - f.Printf("A new version (%v) was installed.\n", event.Version.Version) + f.Printf("A new version (%v) was installed.\n", event.GetLatestVersion()) case events.UpdateFailed: - f.Printf("A new version (%v) failed to be installed (%v).\n", event.Version.Version, event.Error) + f.Printf("A new version (%v) failed to be installed (%v).\n", event.GetLatestVersion(), event.Error) case events.UpdateForced: f.notifyNeedUpgrade() diff --git a/internal/frontend/grpc/service.go b/internal/frontend/grpc/service.go index 610c54b0..82cfc710 100644 --- a/internal/frontend/grpc/service.go +++ b/internal/frontend/grpc/service.go @@ -78,11 +78,13 @@ type Service struct { // nolint:structcheck eventCh <-chan events.Event quitCh <-chan struct{} - latest updater.VersionInfo - latestLock safe.RWMutex + latestLegacy updater.VersionInfoLegacy + latest updater.Release + latestLock safe.RWMutex - target updater.VersionInfo - targetLock safe.RWMutex + targetLegacy updater.VersionInfoLegacy + target updater.Release + targetLock safe.RWMutex authClient *proton.Client auth proton.Auth @@ -168,11 +170,13 @@ func NewService( eventCh: eventCh, quitCh: quitCh, - latest: updater.VersionInfo{}, - latestLock: safe.NewRWMutex(), + latestLegacy: updater.VersionInfoLegacy{}, + latest: updater.Release{}, + latestLock: safe.NewRWMutex(), - target: updater.VersionInfo{}, - targetLock: safe.NewRWMutex(), + targetLegacy: updater.VersionInfoLegacy{}, + target: updater.Release{}, + targetLock: safe.NewRWMutex(), log: logrus.WithField("pkg", "grpc"), initializing: sync.WaitGroup{}, @@ -354,10 +358,11 @@ func (s *Service) watchEvents() { case events.UpdateLatest: safe.RLock(func() { - s.latest = event.Version + s.latestLegacy = event.VersionLegacy + s.latest = event.Release }, s.latestLock) - _ = s.SendEvent(NewUpdateVersionChangedEvent()) + _ = s.SendEvent(NewUpdateVersionChangedEvent()) // This updates the release notes page and landing page. case events.UpdateAvailable: switch { @@ -366,10 +371,11 @@ func (s *Service) watchEvents() { case !event.Silent: safe.RLock(func() { - s.target = event.Version + s.targetLegacy = event.VersionLegacy + s.target = event.Release }, s.targetLock) - _ = s.SendEvent(NewUpdateManualReadyEvent(event.Version.Version.String())) + _ = s.SendEvent(NewUpdateManualReadyEvent(event.GetLatestVersion())) } case events.UpdateInstalled: @@ -391,8 +397,10 @@ func (s *Service) watchEvents() { if s.latest.Version != nil { latest = s.latest.Version.String() - } else if version, ok := s.checkLatestVersion(); ok { - latest = version.Version.String() + } else if s.latestLegacy.Version != nil { + latest = s.latestLegacy.Version.String() + } else if latestVersion, ok := s.checkLatestVersion(); ok { + latest = latestVersion } else { latest = "unknown" } @@ -517,7 +525,7 @@ func (s *Service) triggerReset() { s.bridge.FactoryReset(context.Background()) } -func (s *Service) checkLatestVersion() (updater.VersionInfo, bool) { +func (s *Service) checkLatestVersion() (string, bool) { updateCh, done := s.bridge.GetEvents(events.UpdateLatest{}) defer done() @@ -526,14 +534,13 @@ func (s *Service) checkLatestVersion() (updater.VersionInfo, bool) { select { case event := <-updateCh: if latest, ok := event.(events.UpdateLatest); ok { - return latest.Version, true + return latest.GetLatestVersion(), true } - case <-time.After(5 * time.Second): // ... } - return updater.VersionInfo{}, false + return "", false } func newTLSConfig() (*tls.Config, []byte, error) { diff --git a/internal/frontend/grpc/service_methods.go b/internal/frontend/grpc/service_methods.go index bafe19c4..69d10efa 100644 --- a/internal/frontend/grpc/service_methods.go +++ b/internal/frontend/grpc/service_methods.go @@ -298,7 +298,14 @@ func (s *Service) ReleaseNotesPageLink(_ context.Context, _ *emptypb.Empty) (*wr s.latestLock.RUnlock() }() - return wrapperspb.String(s.latest.ReleaseNotesPage), nil + var releaseNotesPage string + if !s.latestLegacy.IsEmpty() { + releaseNotesPage = s.latestLegacy.ReleaseNotesPage + } else if !s.latest.IsEmpty() { + releaseNotesPage = s.latest.ReleaseNotesPage + } + + return wrapperspb.String(releaseNotesPage), nil } func (s *Service) LandingPageLink(_ context.Context, _ *emptypb.Empty) (*wrapperspb.StringValue, error) { @@ -308,7 +315,14 @@ func (s *Service) LandingPageLink(_ context.Context, _ *emptypb.Empty) (*wrapper s.latestLock.RUnlock() }() - return wrapperspb.String(s.latest.LandingPage), nil + var landingPage string + if !s.latestLegacy.IsEmpty() { + landingPage = s.latestLegacy.LandingPage + } else if !s.latest.IsEmpty() { + landingPage = s.latest.LandingPage + } + + return wrapperspb.String(landingPage), nil } func (s *Service) SetColorSchemeName(_ context.Context, name *wrapperspb.StringValue) (*emptypb.Empty, error) { @@ -617,7 +631,11 @@ func (s *Service) InstallUpdate(_ context.Context, _ *emptypb.Empty) (*emptypb.E defer async.HandlePanic(s.panicHandler) safe.RLock(func() { - s.bridge.InstallUpdate(s.target) + if !s.targetLegacy.IsEmpty() { + s.bridge.InstallUpdateLegacy(s.targetLegacy) + } else if !s.target.IsEmpty() { + s.bridge.InstallUpdate(s.target) + } }, s.targetLock) }() diff --git a/internal/unleash/service.go b/internal/unleash/service.go index b4fe9b36..691a3125 100644 --- a/internal/unleash/service.go +++ b/internal/unleash/service.go @@ -37,9 +37,10 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals const filename = "unleash_flags" const ( - EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled" - IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled" - UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled" + EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled" + IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled" + UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled" + UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled" ) type requestFeaturesFn func(ctx context.Context) (proton.FeatureFlagResult, error) diff --git a/internal/updater/types_test.go b/internal/updater/types_test.go new file mode 100644 index 00000000..8c6efec7 --- /dev/null +++ b/internal/updater/types_test.go @@ -0,0 +1,255 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package updater + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_ReleaseCategory_UpdateEligible(t *testing.T) { + // If release is beta only beta users can update + require.True(t, EarlyAccessReleaseCategory.UpdateEligible(EarlyChannel)) + require.False(t, EarlyAccessReleaseCategory.UpdateEligible(StableChannel)) + + // If the release is stable and is the newest then both beta and stable users can update + require.True(t, StableReleaseCategory.UpdateEligible(EarlyChannel)) + require.True(t, StableReleaseCategory.UpdateEligible(StableChannel)) +} + +func Test_ReleaseCategory_JsonUnmarshal(t *testing.T) { + tests := []struct { + input string + expected ReleaseCategory + wantErr bool + }{ + { + input: `{"ReleaseCategory": "EarlyAccess"}`, + expected: EarlyAccessReleaseCategory, + }, + { + input: `{"ReleaseCategory": "Earlyaccess"}`, + expected: EarlyAccessReleaseCategory, + }, + { + input: `{"ReleaseCategory": "earlyaccess"}`, + expected: EarlyAccessReleaseCategory, + }, + { + input: `{"ReleaseCategory": " earlyaccess "}`, + expected: EarlyAccessReleaseCategory, + }, + { + input: `{"ReleaseCategory": "Stable"}`, + expected: StableReleaseCategory, + }, + { + input: `{"ReleaseCategory": "Stable "}`, + expected: StableReleaseCategory, + }, + { + input: `{"ReleaseCategory": "stable"}`, + expected: StableReleaseCategory, + }, + { + input: `{"ReleaseCategory": "invalid"}`, + wantErr: true, + }, + } + + var data struct { + ReleaseCategory ReleaseCategory + } + + for _, test := range tests { + err := json.Unmarshal([]byte(test.input), &data) + if err != nil && !test.wantErr { + t.Errorf("json.Unmarshal() error = %v, wantErr %v", err, test.wantErr) + return + } + + if test.wantErr && err == nil { + t.Errorf("expected err got nil") + } + + if !test.wantErr && data.ReleaseCategory != test.expected { + t.Errorf("got %v, want %v", data.ReleaseCategory, test.expected) + } + } +} + +func Test_ReleaseCategory_JsonMarshal(t *testing.T) { + tests := []struct { + input struct { + ReleaseCategory ReleaseCategory `json:"ReleaseCategory"` + } + expectedOutput string + wantErr bool + }{ + { + input: struct { + ReleaseCategory ReleaseCategory `json:"ReleaseCategory"` + }{ReleaseCategory: StableReleaseCategory}, + expectedOutput: `{"ReleaseCategory":"Stable"}`, + }, + { + input: struct { + ReleaseCategory ReleaseCategory `json:"ReleaseCategory"` + }{ReleaseCategory: EarlyAccessReleaseCategory}, + expectedOutput: `{"ReleaseCategory":"EarlyAccess"}`, + }, + { + input: struct { + ReleaseCategory ReleaseCategory `json:"ReleaseCategory"` + }{ReleaseCategory: 4}, + wantErr: true, + }, + } + + for _, test := range tests { + output, err := json.Marshal(test.input) + + if test.wantErr { + if err == nil && len(output) == 0 { + t.Errorf("expected error or non-empty output for invalid category") + return + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if string(output) != test.expectedOutput { + t.Errorf("json.Marshal() = %v, want %v", string(output), test.expectedOutput) + } + } + } +} + +func Test_FileIdentifier_JsonUnmarshal(t *testing.T) { + tests := []struct { + input string + expected FileIdentifier + wantErr bool + }{ + { + input: `{"Identifier": "package"}`, + expected: PackageIdentifier, + }, + { + input: `{"Identifier": "Package"}`, + expected: PackageIdentifier, + }, + { + input: `{"Identifier": "pACKage"}`, + expected: PackageIdentifier, + }, + { + input: `{"Identifier": "pACKage "}`, + expected: PackageIdentifier, + }, + { + input: `{"Identifier": "installer"}`, + expected: InstallerIdentifier, + }, + { + input: `{"Identifier": "Installer"}`, + expected: InstallerIdentifier, + }, + { + input: `{"Identifier": "iNSTaller "}`, + expected: InstallerIdentifier, + }, + { + input: `{"Identifier": "error"}`, + wantErr: true, + }, + } + + var data struct { + Identifier FileIdentifier + } + + for _, test := range tests { + err := json.Unmarshal([]byte(test.input), &data) + if err != nil && !test.wantErr { + t.Errorf("json.Unmarshal() error = %v, wantErr %v", err, test.wantErr) + return + } + + if test.wantErr && err == nil { + t.Errorf("expected err got nil") + } + + if !test.wantErr && data.Identifier != test.expected { + t.Errorf("got %v, want %v", data.Identifier, test.expected) + } + } +} + +func Test_FileIdentifier_JsonMarshal(t *testing.T) { + tests := []struct { + input struct { + Identifier FileIdentifier + } + expectedOutput string + wantErr bool + }{ + { + input: struct { + Identifier FileIdentifier + }{Identifier: PackageIdentifier}, + expectedOutput: `{"Identifier":"package"}`, + }, + { + input: struct { + Identifier FileIdentifier + }{Identifier: InstallerIdentifier}, + expectedOutput: `{"Identifier":"installer"}`, + }, + { + input: struct { + Identifier FileIdentifier + }{Identifier: 4}, + wantErr: true, + }, + } + + for _, test := range tests { + output, err := json.Marshal(test.input) + + if test.wantErr { + if err == nil && len(output) == 0 { + t.Errorf("expected error or non-empty output for invalid identifier") + return + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if string(output) != test.expectedOutput { + t.Errorf("json.Marshal() = %v, want %v", string(output), test.expectedOutput) + } + } + } +} diff --git a/internal/updater/types_version.go b/internal/updater/types_version.go new file mode 100644 index 00000000..ae99f1b3 --- /dev/null +++ b/internal/updater/types_version.go @@ -0,0 +1,135 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package updater + +import ( + "encoding/json" + "fmt" + "strings" +) + +type ReleaseCategory uint8 +type FileIdentifier uint8 + +const ( + EarlyAccessReleaseCategory ReleaseCategory = iota + StableReleaseCategory +) + +const ( + PackageIdentifier FileIdentifier = iota + InstallerIdentifier +) + +var ( + releaseCategoryName = map[uint8]string{ //nolint:gochecknoglobals + 0: "EarlyAccess", + 1: "Stable", + } + releaseCategoryValue = map[string]uint8{ //nolint:gochecknoglobals + "earlyaccess": 0, + "stable": 1, + } + fileIdentifierName = map[uint8]string{ //nolint:gochecknoglobals + 0: "package", + 1: "installer", + } + fileIdentifierValue = map[string]uint8{ //nolint:gochecknoglobals + "package": 0, + "installer": 1, + } +) + +func ParseFileIdentifier(s string) (FileIdentifier, error) { + s = strings.TrimSpace(strings.ToLower(s)) + val, ok := fileIdentifierValue[s] + if !ok { + return FileIdentifier(0), fmt.Errorf("%s is not a valid file identifier", s) + } + + return FileIdentifier(val), nil +} + +func (fi FileIdentifier) String() string { + return fileIdentifierName[uint8(fi)] +} + +func (fi FileIdentifier) MarshalJSON() ([]byte, error) { + return json.Marshal(fi.String()) +} + +func (fi *FileIdentifier) UnmarshalJSON(data []byte) (err error) { + var fileIdentifier string + if err := json.Unmarshal(data, &fileIdentifier); err != nil { + return err + } + + parsedFileIdentifier, err := ParseFileIdentifier(fileIdentifier) + if err != nil { + return err + } + + *fi = parsedFileIdentifier + return nil +} + +func ParseReleaseCategory(s string) (ReleaseCategory, error) { + s = strings.TrimSpace(strings.ToLower(s)) + val, ok := releaseCategoryValue[s] + if !ok { + return ReleaseCategory(0), fmt.Errorf("%s is not a valid release category", s) + } + + return ReleaseCategory(val), nil +} + +func (rc ReleaseCategory) String() string { + return releaseCategoryName[uint8(rc)] +} + +func (rc ReleaseCategory) MarshalJSON() ([]byte, error) { + return json.Marshal(rc.String()) +} + +func (rc *ReleaseCategory) UnmarshalJSON(data []byte) (err error) { + var releaseCat string + if err := json.Unmarshal(data, &releaseCat); err != nil { + return err + } + + parsedCat, err := ParseReleaseCategory(releaseCat) + if err != nil { + return err + } + + *rc = parsedCat + + return nil +} + +func (rc ReleaseCategory) UpdateEligible(channel Channel) bool { + if channel == StableChannel && rc == StableReleaseCategory { + return true + } + + if channel == EarlyChannel && rc == EarlyAccessReleaseCategory || rc == StableReleaseCategory { + return true + } + + return false +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 14216c3e..9b93ef9a 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -29,13 +29,17 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/versioner" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" ) +const updateFileVersion = 1 + var ( ErrDownloadVerify = errors.New("failed to download or verify the update") ErrInstall = errors.New("failed to install the update") ErrUpdateAlreadyInstalled = errors.New("update is already installed") ErrVersionFileDownloadOrVerify = errors.New("failed to download or verify the version file") + ErrReleaseUpdatePackageMissing = errors.New("release update package is missing") ) type Downloader interface { @@ -53,6 +57,7 @@ type Updater struct { verifier *crypto.KeyRing product string platform string + version uint } func NewUpdater(ver *versioner.Versioner, verifier *crypto.KeyRing, product, platform string) *Updater { @@ -62,10 +67,36 @@ func NewUpdater(ver *versioner.Versioner, verifier *crypto.KeyRing, product, pla verifier: verifier, product: product, platform: platform, + version: updateFileVersion, } } -func (u *Updater) GetVersionInfo(ctx context.Context, downloader Downloader, channel Channel) (VersionInfo, error) { +func (u *Updater) GetVersionInfoLegacy(ctx context.Context, downloader Downloader, channel Channel) (VersionInfoLegacy, error) { + b, err := downloader.DownloadAndVerify( + ctx, + u.verifier, + u.getVersionFileURLLegacy(), + u.getVersionFileURLLegacy()+".sig", + ) + if err != nil { + return VersionInfoLegacy{}, fmt.Errorf("%w: %w", ErrVersionFileDownloadOrVerify, err) + } + + var versionMap VersionMap + + if err := json.Unmarshal(b, &versionMap); err != nil { + return VersionInfoLegacy{}, err + } + + version, ok := versionMap[channel] + if !ok { + return VersionInfoLegacy{}, errors.New("no updates available for this channel") + } + + return version, nil +} + +func (u *Updater) GetVersionInfo(ctx context.Context, downloader Downloader) (VersionInfo, error) { b, err := downloader.DownloadAndVerify( ctx, u.verifier, @@ -76,21 +107,16 @@ func (u *Updater) GetVersionInfo(ctx context.Context, downloader Downloader, cha return VersionInfo{}, fmt.Errorf("%w: %w", ErrVersionFileDownloadOrVerify, err) } - var versionMap VersionMap + var releases VersionInfo - if err := json.Unmarshal(b, &versionMap); err != nil { + if err := json.Unmarshal(b, &releases); err != nil { return VersionInfo{}, err } - version, ok := versionMap[channel] - if !ok { - return VersionInfo{}, errors.New("no updates available for this channel") - } - - return version, nil + return releases, nil } -func (u *Updater) InstallUpdate(ctx context.Context, downloader Downloader, update VersionInfo) error { +func (u *Updater) InstallUpdateLegacy(ctx context.Context, downloader Downloader, update VersionInfoLegacy) error { if u.installer.IsAlreadyInstalled(update.Version) { return ErrUpdateAlreadyInstalled } @@ -113,13 +139,64 @@ func (u *Updater) InstallUpdate(ctx context.Context, downloader Downloader, upda return nil } +func (u *Updater) InstallUpdate(ctx context.Context, downloader Downloader, release Release) error { + if u.installer.IsAlreadyInstalled(release.Version) { + return ErrUpdateAlreadyInstalled + } + + // Find update package + idx := slices.IndexFunc(release.File, func(file File) bool { + return file.Identifier == PackageIdentifier + }) + + if idx == -1 { + logrus.WithFields(logrus.Fields{ + "release_version": release.Version, + }).Error("Update release does not contain update package") + return ErrReleaseUpdatePackageMissing + } + + releaseUpdatePackage := release.File[idx] + + b, err := downloader.DownloadAndVerify( + ctx, + u.verifier, + releaseUpdatePackage.URL, + releaseUpdatePackage.URL+".sig", + ) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownloadVerify, err) + } + + if err := u.installer.InstallUpdate(release.Version, bytes.NewReader(b)); err != nil { + logrus.WithError(err).Error("Failed to install update") + return ErrInstall + } + + return nil +} + func (u *Updater) RemoveOldUpdates() error { return u.versioner.RemoveOldVersions() } -// getVersionFileURL returns the URL of the version file. +// getVersionFileURLLegacy returns the URL of the version file. // For example: // - https://protonmail.com/download/bridge/version_linux.json -func (u *Updater) getVersionFileURL() string { +func (u *Updater) getVersionFileURLLegacy() string { return fmt.Sprintf("%v/%v/version_%v.json", Host, u.product, u.platform) } + +// getVersionFileURL returns the URL of the version file. +// For example: +// - https://protonmail.com/download/windows/x86/v1/version.json +// - https://protonmail.com/download/linux/x86/v1/version.json +// - https://protonmail.com/download/darwin/universal/v1/version.json +func (u *Updater) getVersionFileURL() string { + switch u.platform { + case "darwin": + return fmt.Sprintf("%v/%v/%v/universal/v%v/version.json", Host, u.product, u.platform, u.version) + default: + return fmt.Sprintf("%v/%v/%v/x86/v%v/version.json", Host, u.product, u.platform, u.version) + } +} diff --git a/internal/updater/version.go b/internal/updater/version.go index 42ed3b8d..aa98a8b8 100644 --- a/internal/updater/version.go +++ b/internal/updater/version.go @@ -19,10 +19,36 @@ package updater import ( "github.com/Masterminds/semver/v3" + "github.com/ProtonMail/proton-bridge/v3/internal/updater/versioncompare" ) -// VersionInfo is information about one version of the app. +type File struct { + URL string `json:"Url"` + Sha512CheckSum string `json:"Sha512CheckSum,omitempty"` + Identifier FileIdentifier `json:"Identifier"` +} + +type Release struct { + ReleaseCategory ReleaseCategory `json:"CategoryName"` + Version *semver.Version + SystemVersion versioncompare.SystemVersion `json:"SystemVersion,omitempty"` + RolloutProportion float64 + MinAuto *semver.Version `json:"MinAuto,omitempty"` + ReleaseNotesPage string + LandingPage string + File []File `json:"File"` +} + +func (rel Release) IsEmpty() bool { + return rel.Version == nil && len(rel.File) == 0 +} + type VersionInfo struct { + Releases []Release `json:"Releases"` +} + +// VersionInfoLegacy is information about one version of the app. +type VersionInfoLegacy struct { // Version is the semantic version of the release. Version *semver.Version @@ -46,6 +72,10 @@ type VersionInfo struct { RolloutProportion float64 } +func (verInfo VersionInfoLegacy) IsEmpty() bool { + return verInfo.Version == nil && verInfo.ReleaseNotesPage == "" +} + // VersionMap represents the structure of the version.json file. // It looks like this: // @@ -79,4 +109,4 @@ type VersionInfo struct { // } // }. -type VersionMap map[Channel]VersionInfo +type VersionMap map[Channel]VersionInfoLegacy diff --git a/internal/updater/version_test.go b/internal/updater/version_test.go new file mode 100644 index 00000000..e036cea1 --- /dev/null +++ b/internal/updater/version_test.go @@ -0,0 +1,205 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package updater + +import ( + "encoding/json" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/ProtonMail/proton-bridge/v3/internal/updater/versioncompare" +) + +var mockJSONData = ` +{ + "Releases": [ + { + "CategoryName": "Stable", + "Version": "2.1.0", + "ReleaseDate": "2025-01-15T08:00:00Z", + "File": [ + { + "Url": "https://downloads.example.com/v2.1.0/MyApp-2.1.0.pkg", + "Sha512CheckSum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Identifier": "package" + }, + { + "Url": "https://downloads.example.com/v2.1.0/MyApp-2.1.0.dmg", + "Sha512CheckSum": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", + "Identifier": "installer" + } + ], + "RolloutProportion": 0.5, + "MinAuto": "2.0.0", + "Commit": "8f52d45c9f8c31aa391315ea24e40c4a7e0b2c1d", + "ReleaseNotesPage": "https://example.com/releases/2.1.0/notes", + "LandingPage": "https://example.com/releases/2.1.0" + }, + { + "CategoryName": "EarlyAccess", + "Version": "2.2.0-beta.1", + "ReleaseDate": "2025-01-20T10:00:00Z", + "File": [ + { + "Url": "https://downloads.example.com/beta/v2.2.0-beta.1/MyApp-2.2.0-beta.1.pkg", + "Sha512CheckSum": "a9f0e44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Identifier": "package" + } + ], + "SystemVersion": { + "Minimum": "13" + }, + "RolloutProportion": 0.25, + "MinAuto": "2.1.0", + "Commit": "3e72d45c9f8c31aa391315ea24e40c4a7e0b2c1d", + "ReleaseNotesPage": "https://example.com/releases/2.2.0-beta.1/notes", + "LandingPage": "https://example.com/releases/2.2.0-beta.1" + }, + { + "CategoryName": "Stable", + "Version": "2.0.0", + "ReleaseDate": "2024-12-01T09:00:00Z", + "File": [ + { + "Url": "https://downloads.example.com/v2.0.0/MyApp-2.0.0.pkg", + "Sha512CheckSum": "b5f0e44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Identifier": "package" + }, + { + "Url": "https://downloads.example.com/v2.0.0/MyApp-2.0.0.dmg", + "Sha512CheckSum": "d583e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", + "Identifier": "installer" + } + ], + "SystemVersion": { + "Maximum": "12.0.0", + "Minimum": "1.0.0" + }, + "RolloutProportion": 1.0, + "MinAuto": "1.9.0", + "Commit": "2a42d45c9f8c31aa391315ea24e40c4a7e0b2c1d", + "ReleaseNotesPage": "https://example.com/releases/2.0.0/notes", + "LandingPage": "https://example.com/releases/2.0.0" + } + ] +} +` + +var expectedVersionInfo = VersionInfo{ + Releases: []Release{ + { + ReleaseCategory: StableReleaseCategory, + Version: semver.MustParse("2.1.0"), + RolloutProportion: 0.5, + MinAuto: semver.MustParse("2.0.0"), + File: []File{ + { + URL: "https://downloads.example.com/v2.1.0/MyApp-2.1.0.pkg", + Identifier: PackageIdentifier, + }, + { + URL: "https://downloads.example.com/v2.1.0/MyApp-2.1.0.dmg", + Identifier: InstallerIdentifier, + }, + }, + }, + { + ReleaseCategory: EarlyAccessReleaseCategory, + Version: semver.MustParse("2.2.0-beta.1"), + RolloutProportion: 0.25, + MinAuto: semver.MustParse("2.1.0"), + File: []File{ + { + URL: "https://downloads.example.com/beta/v2.2.0-beta.1/MyApp-2.2.0-beta.1.pkg", + Identifier: PackageIdentifier, + }, + }, + SystemVersion: versioncompare.SystemVersion{Minimum: "13"}, + }, + { + ReleaseCategory: StableReleaseCategory, + Version: semver.MustParse("2.0.0"), + RolloutProportion: 1.0, + MinAuto: semver.MustParse("1.9.0"), + SystemVersion: versioncompare.SystemVersion{Maximum: "12.0.0", Minimum: "1.0.0"}, + File: []File{ + { + URL: "https://downloads.example.com/v2.0.0/MyApp-2.0.0.pkg", + Identifier: PackageIdentifier, + }, + { + URL: "https://downloads.example.com/v2.0.0/MyApp-2.0.0.dmg", + Identifier: InstallerIdentifier, + }, + }, + }, + }, +} + +func Test_Releases_JsonParse(t *testing.T) { + var versionInfo VersionInfo + if err := json.Unmarshal([]byte(mockJSONData), &versionInfo); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if len(expectedVersionInfo.Releases) != len(versionInfo.Releases) { + t.Fatalf("expected %d releases, parsed %d releases", len(expectedVersionInfo.Releases), len(versionInfo.Releases)) + } + + for i, expectedRelease := range expectedVersionInfo.Releases { + release := versionInfo.Releases[i] + + if release.ReleaseCategory != expectedRelease.ReleaseCategory { + t.Errorf("Release %d: expected category %v, got %v", i, expectedRelease.ReleaseCategory, release.ReleaseCategory) + } + + if release.Version.String() != expectedRelease.Version.String() { + t.Errorf("Release %d: expected version %s, got %s", i, expectedRelease.Version, release.Version) + } + + if release.RolloutProportion != expectedRelease.RolloutProportion { + t.Errorf("Release %d: expected rollout proportion %f, got %f", i, expectedRelease.RolloutProportion, release.RolloutProportion) + } + + if expectedRelease.MinAuto != nil && release.MinAuto.String() != expectedRelease.MinAuto.String() { + t.Errorf("Release %d: expected min auto %s, got %s", i, expectedRelease.MinAuto, release.MinAuto) + } + + if expectedRelease.SystemVersion.Minimum != release.SystemVersion.Minimum { + t.Errorf("Release %d: expected system version minimum %s, got %s", i, expectedRelease.SystemVersion.Minimum, release.SystemVersion.Minimum) + } + + if expectedRelease.SystemVersion.Maximum != release.SystemVersion.Maximum { + t.Errorf("Release %d: expected system version minimum %s, got %s", i, expectedRelease.SystemVersion.Maximum, release.SystemVersion.Maximum) + } + + if len(release.File) != len(expectedRelease.File) { + t.Errorf("Release %d: expected %d files, got %d", i, len(expectedRelease.File), len(release.File)) + } + + for j, expectedFile := range expectedRelease.File { + file := release.File[j] + if file.URL != expectedFile.URL { + t.Errorf("Release %d, File %d: expected URL %s, got %s", i, j, expectedFile.URL, file.URL) + } + if file.Identifier != expectedFile.Identifier { + t.Errorf("Release %d, File %d: expected Identifier %v, got %v", i, j, expectedFile.Identifier, file.Identifier) + } + } + } +} diff --git a/internal/updater/versioncompare/compare_darwin.go b/internal/updater/versioncompare/compare_darwin.go new file mode 100644 index 00000000..5b219e98 --- /dev/null +++ b/internal/updater/versioncompare/compare_darwin.go @@ -0,0 +1,134 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +//go:build darwin + +package versioncompare + +import ( + "fmt" + "strconv" + "strings" + + "github.com/elastic/go-sysinfo/types" + "github.com/sirupsen/logrus" +) + +func (sysVer SystemVersion) IsHostVersionEligible(log *logrus.Entry, host types.Host, getHostOSVersion func(host types.Host) string) (bool, error) { + if sysVer.Minimum == "" && sysVer.Maximum == "" { + return true, nil + } + + // We use getHostOSVersion simply for testing; It's passed via Bridge. + var hostVersion string + if getHostOSVersion == nil { + hostVersion = host.Info().OS.Version + } else { + hostVersion = getHostOSVersion(host) + } + + log.Debugf("Checking host OS and update system version requirements. Host: %s; Maximum: %s; Minimum: %s", + hostVersion, sysVer.Maximum, sysVer.Minimum) + + hostVersionArr := strings.Split(hostVersion, ".") + if len(hostVersionArr) == 0 || hostVersion == "" { + return true, fmt.Errorf("could not get host version: %v", hostVersion) + } + + hostVersionArrInt := make([]int, len(hostVersionArr)) + for i := 0; i < len(hostVersionArr); i++ { + hostNum, err := strconv.Atoi(hostVersionArr[i]) + if err != nil { + // If we receive an alphanumeric version - we should continue with the update and stop checking for + // OS version requirements. + return true, fmt.Errorf("invalid host version number: %s - %s", hostVersionArr[i], hostVersion) + } + hostVersionArrInt[i] = hostNum + } + + if sysVer.Minimum != "" { + pass, err := compareMinimumVersion(hostVersionArrInt, sysVer.Minimum) + if err != nil { + return false, err + } + + if !pass { + return false, fmt.Errorf("host version is below minimum: hostVersion %v - minimumVersion %v", hostVersion, sysVer.Minimum) + } + } + + if sysVer.Maximum != "" { + pass, err := compareMaximumVersion(hostVersionArrInt, sysVer.Maximum) + if err != nil { + return false, err + } + + if !pass { + return false, fmt.Errorf("host version is above maximum version: hostVersion %v - minimumVersion %v", hostVersion, sysVer.Maximum) + } + } + + return true, nil +} + +func compareMinimumVersion(hostVersionArr []int, minVersion string) (bool, error) { + minVersionArr := strings.Split(minVersion, ".") + iterationDepth := min(len(hostVersionArr), len(minVersionArr)) + + for i := 0; i < iterationDepth; i++ { + hostNum := hostVersionArr[i] + + minNum, err := strconv.Atoi(minVersionArr[i]) + if err != nil { + return false, fmt.Errorf("invalid minimum version number: %s - %s", minVersionArr[i], minVersion) + } + + if hostNum < minNum { + return false, nil + } + + if hostNum > minNum { + return true, nil + } + } + + return true, nil // minVersion is inclusive +} + +func compareMaximumVersion(hostVersionArr []int, maxVersion string) (bool, error) { + maxVersionArr := strings.Split(maxVersion, ".") + iterationDepth := min(len(maxVersionArr), len(hostVersionArr)) + + for i := 0; i < iterationDepth; i++ { + hostNum := hostVersionArr[i] + + maxNum, err := strconv.Atoi(maxVersionArr[i]) + if err != nil { + return false, fmt.Errorf("invalid maximum version number: %s - %s", maxVersionArr[i], maxVersion) + } + + if hostNum > maxNum { + return false, nil + } + + if hostNum < maxNum { + return true, nil + } + } + + return true, nil // maxVersion is inclusive +} diff --git a/internal/updater/versioncompare/compare_darwin_test.go b/internal/updater/versioncompare/compare_darwin_test.go new file mode 100644 index 00000000..abf6fc63 --- /dev/null +++ b/internal/updater/versioncompare/compare_darwin_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +//go:build darwin + +package versioncompare + +import ( + "testing" + + "github.com/elastic/go-sysinfo" + "github.com/elastic/go-sysinfo/types" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func Test_IsHost_EligibleDarwin(t *testing.T) { + host, err := sysinfo.Host() + require.NoError(t, err) + + testData := []struct { + sysVer SystemVersion + getHostOsVersionFn func(host types.Host) string + shouldContinue bool + wantErr bool + }{ + { + sysVer: SystemVersion{Minimum: "9.5", Maximum: "12.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "10.0" }, + shouldContinue: true, + }, + { + sysVer: SystemVersion{Minimum: "9.5.5.5", Maximum: "10.1.1.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "10.0" }, + shouldContinue: true, + }, + { + sysVer: SystemVersion{Minimum: "10.0.1", Maximum: "12.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "10.0" }, + shouldContinue: true, + }, + { + sysVer: SystemVersion{Minimum: "11.0", Maximum: "12.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "10.0" }, + shouldContinue: false, + wantErr: true, + }, + { + sysVer: SystemVersion{Minimum: "11.1.0", Maximum: "12.0.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "11.0.0" }, + shouldContinue: false, + wantErr: true, + }, + { + sysVer: SystemVersion{Minimum: "10.0", Maximum: "12.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "12.0" }, + shouldContinue: true, + }, + { + sysVer: SystemVersion{Minimum: "11.1.0", Maximum: "12.0.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "" }, + shouldContinue: true, + wantErr: true, + }, + { + sysVer: SystemVersion{Minimum: "11.1.0", Maximum: "12.0.0"}, + getHostOsVersionFn: func(_ types.Host) string { return "a.b.c" }, + shouldContinue: true, + wantErr: true, + }, + { + sysVer: SystemVersion{}, + getHostOsVersionFn: func(_ types.Host) string { return "1.2.3" }, + shouldContinue: true, + wantErr: false, + }, + } + + for _, test := range testData { + l := logrus.WithField("test", "test") + shouldContinue, err := test.sysVer.IsHostVersionEligible(l, host, test.getHostOsVersionFn) + + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + require.Equal(t, test.shouldContinue, shouldContinue) + } +} diff --git a/internal/updater/versioncompare/compare_linux.go b/internal/updater/versioncompare/compare_linux.go new file mode 100644 index 00000000..9a8d2ba1 --- /dev/null +++ b/internal/updater/versioncompare/compare_linux.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +//go:build linux + +package versioncompare + +import ( + "github.com/elastic/go-sysinfo/types" + "github.com/sirupsen/logrus" +) + +// IsHostVersionEligible - Checks whether host OS version is eligible for update. Defaults to true on Linux. +func (sysVer SystemVersion) IsHostVersionEligible(log *logrus.Entry, _ types.Host, _ func(host types.Host) string) (bool, error) { + log.Info("Checking host OS version on Linux. Defaulting to true.") + return true, nil +} diff --git a/internal/updater/versioncompare/compare_windows.go b/internal/updater/versioncompare/compare_windows.go new file mode 100644 index 00000000..c7192328 --- /dev/null +++ b/internal/updater/versioncompare/compare_windows.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +//go:build windows + +package versioncompare + +import ( + "github.com/elastic/go-sysinfo/types" + "github.com/sirupsen/logrus" +) + +// IsHostVersionEligible - Checks whether host OS version is eligible for update. Defaults to true on Linux. +func (sysVer SystemVersion) IsHostVersionEligible(log *logrus.Entry, _ types.Host, _ func(host types.Host) string) (bool, error) { + log.Info("Checking host OS version on Windows. Defaulting to true.") + return true, nil +} diff --git a/internal/updater/versioncompare/types.go b/internal/updater/versioncompare/types.go new file mode 100644 index 00000000..4adc4b95 --- /dev/null +++ b/internal/updater/versioncompare/types.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package versioncompare + +import "fmt" + +type SystemVersion struct { + Minimum string `json:"Minimum,omitempty"` + Maximum string `json:"Maximum,omitempty"` +} + +func (sysVer SystemVersion) String() string { + return fmt.Sprintf("SystemVersion: Maximum %s, Minimum %s", sysVer.Maximum, sysVer.Minimum) +} diff --git a/tests/api_test.go b/tests/api_test.go index 22c2af93..0f2fc4dc 100644 --- a/tests/api_test.go +++ b/tests/api_test.go @@ -36,6 +36,8 @@ type API interface { GetDomain() string GetAppVersion() string + PushFeatureFlag(string) + Close() } @@ -61,6 +63,10 @@ func (api *fakeAPI) GetAppVersion() string { return proton.DefaultAppVersion } +func (api *fakeAPI) PushFeatureFlag(flagName string) { + api.Server.PushFeatureFlag(flagName) +} + type liveAPI struct { *server.Server diff --git a/tests/bridge_test.go b/tests/bridge_test.go index 0b801ab0..55887919 100644 --- a/tests/bridge_test.go +++ b/tests/bridge_test.go @@ -32,6 +32,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/kb" + "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/cucumber/godog" "github.com/golang/mock/gomock" @@ -55,7 +56,7 @@ func (s *scenario) bridgeStops() error { func (s *scenario) bridgeVersionIsAndTheLatestAvailableVersionIsReachableFrom(current, latest, minAuto string) error { s.t.version = semver.MustParse(current) - s.t.mocks.Updater.SetLatestVersion(semver.MustParse(latest), semver.MustParse(minAuto)) + s.t.mocks.Updater.SetLatestVersionLegacy(semver.MustParse(latest), semver.MustParse(minAuto)) return nil } @@ -361,8 +362,8 @@ func (s *scenario) bridgeSendsAnUpdateAvailableEventForVersion(version string) e return errors.New("expected update event to be installable") } - if !event.Version.Version.Equal(semver.MustParse(version)) { - return fmt.Errorf("expected update event for version %s, got %s", version, event.Version.Version) + if !event.VersionLegacy.Version.Equal(semver.MustParse(version)) { + return fmt.Errorf("expected update event for version %s, got %s", version, event.VersionLegacy.Version) } return nil @@ -378,8 +379,8 @@ func (s *scenario) bridgeSendsAManualUpdateEventForVersion(version string) error return errors.New("expected update event to not be installable") } - if !event.Version.Version.Equal(semver.MustParse(version)) { - return fmt.Errorf("expected update event for version %s, got %s", version, event.Version.Version) + if !event.VersionLegacy.Version.Equal(semver.MustParse(version)) { + return fmt.Errorf("expected update event for version %s, got %s", version, event.VersionLegacy.Version) } return nil @@ -391,8 +392,8 @@ func (s *scenario) bridgeSendsAnUpdateInstalledEventForVersion(version string) e return errors.New("expected update installed event, got none") } - if !event.Version.Version.Equal(semver.MustParse(version)) { - return fmt.Errorf("expected update installed event for version %s, got %s", version, event.Version.Version) + if !event.VersionLegacy.Version.Equal(semver.MustParse(version)) { + return fmt.Errorf("expected update installed event for version %s, got %s", version, event.VersionLegacy.Version) } return nil @@ -483,3 +484,25 @@ func (s *scenario) bridgeSMTPPortIs(expectedPort int) error { return nil } + +func (s *scenario) bridgeLegacyUpdateKillSwitchEnabled() error { + unleash.ModifyPollPeriodAndJitter(5*time.Second, 0) + s.t.api.PushFeatureFlag(unleash.UpdateUseNewVersionFileStructureDisabled) + return nil +} + +func (s *scenario) bridgeLegacyUpdateEnabled() error { + return eventually(func() error { + res := s.t.bridge.GetFeatureFlagValue(unleash.UpdateUseNewVersionFileStructureDisabled) + fmt.Println("RES", res) + if res != true { + return fmt.Errorf("expected the %v kill-switch to be enabled", unleash.UpdateUseNewVersionFileStructureDisabled) + } + return nil + }) +} + +func (s *scenario) bridgeChecksForUpdates() error { + s.t.bridge.CheckForUpdates() + return nil +} diff --git a/tests/features/bridge/updates.feature b/tests/features/bridge/updates_legacy.feature similarity index 77% rename from tests/features/bridge/updates.feature rename to tests/features/bridge/updates_legacy.feature index f1506afd..31ab1758 100644 --- a/tests/features/bridge/updates.feature +++ b/tests/features/bridge/updates_legacy.feature @@ -1,23 +1,34 @@ Feature: Bridge checks for updates + Background: + Given the legacy update kill switch is enabled + Scenario: Update not available Given bridge is version "2.3.0" and the latest available version is "2.3.0" reachable from "2.3.0" When bridge starts + And bridge verifies that the legacy update is enabled + And bridge checks for updates Then bridge sends an update not available event Scenario: Update available without automatic updates enabled Given bridge is version "2.3.0" and the latest available version is "2.4.0" reachable from "2.3.0" And the user has disabled automatic updates When bridge starts + And bridge verifies that the legacy update is enabled + And bridge checks for updates Then bridge sends an update available event for version "2.4.0" Scenario: Update available with automatic updates enabled Given bridge is version "2.3.0" and the latest available version is "2.4.0" reachable from "2.3.0" When bridge starts + And bridge verifies that the legacy update is enabled + And bridge checks for updates Then bridge sends an update installed event for version "2.4.0" Scenario: Manual update available with automatic updates enabled Given bridge is version "2.3.0" and the latest available version is "2.4.0" reachable from "2.4.0" When bridge starts + And bridge verifies that the legacy update is enabled + And bridge checks for updates Then bridge sends a manual update event for version "2.4.0" Scenario: Update is required to continue using bridge diff --git a/tests/steps_test.go b/tests/steps_test.go index 052ea983..41c2fbac 100644 --- a/tests/steps_test.go +++ b/tests/steps_test.go @@ -99,6 +99,9 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) { ctx.Step(`^bridge reports a message with "([^"]*)"$`, s.bridgeReportsMessage) ctx.Step(`^bridge telemetry feature is enabled$`, s.bridgeTelemetryFeatureEnabled) ctx.Step(`^bridge telemetry feature is disabled$`, s.bridgeTelemetryFeatureDisabled) + ctx.Step(`^the legacy update kill switch is enabled$`, s.bridgeLegacyUpdateKillSwitchEnabled) + ctx.Step(`^bridge verifies that the legacy update is enabled$`, s.bridgeLegacyUpdateEnabled) + ctx.Step(`^bridge checks for updates$`, s.bridgeChecksForUpdates) // ==== FRONTEND ==== ctx.Step(`^frontend sees that bridge is version "([^"]*)"$`, s.frontendSeesThatBridgeIsVersion) diff --git a/utils/versioner/main.go b/utils/versioner/main.go index 04c30613..31cc5dea 100644 --- a/utils/versioner/main.go +++ b/utils/versioner/main.go @@ -31,7 +31,7 @@ import ( ) type versionInfo struct { - updater.VersionInfo + updater.VersionInfoLegacy Commit string }