feat(BRIDGE-309): Update to the bridge updater logic corresponding to the version file restructure

This commit is contained in:
Atanas Janeshliev
2025-01-21 12:34:04 +01:00
parent d711d9f562
commit da0f51ce5f
26 changed files with 2291 additions and 127 deletions

View File

@ -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)
}

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
})
}

View File

@ -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.

View File

@ -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()

View File

@ -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) {

View File

@ -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)
}()

View File

@ -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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
//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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
//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)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
//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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
//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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}