diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go
index 95ad9d2d..2884a84f 100644
--- a/cmd/launcher/main.go
+++ b/cmd/launcher/main.go
@@ -31,6 +31,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/crash"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
@@ -164,7 +165,7 @@ func main() { //nolint:funlen
// On windows, if you use Run(), a terminal stays open; we don't want that.
if //goland:noinspection GoBoolExpressions
- runtime.GOOS == "windows" {
+ runtime.GOOS == platform.WINDOWS {
err = cmd.Start()
} else {
err = cmd.Run()
diff --git a/internal/app/app.go b/internal/app/app.go
index b549f8ed..952baee0 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -39,7 +39,9 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/frontend/theme"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
+ "github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/useragent"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
@@ -285,11 +287,13 @@ func run(c *cli.Context) error {
logrus.WithError(err).Error("Failed to get settings path")
}
+ featureFlags := unleash.GetStartupFeatureFlagsAndStore(constants.APIHost, version, locations.ProvideUnleashStartupCachePath)
+
return withSingleInstance(settings, locations.GetLockFile(), version, func() error {
// Look for available keychains
return WithKeychainList(crashHandler, func(keychains *keychain.List) error {
// Unlock the encrypted vault.
- return WithVault(reporter, locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
+ return WithVault(reporter, locations, keychains, featureFlags, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(v); err != nil {
@@ -577,5 +581,5 @@ func setDeviceCookies(jar *cookies.Jar) error {
}
func onMacOS() bool {
- return runtime.GOOS == "darwin"
+ return runtime.GOOS == platform.MACOS
}
diff --git a/internal/app/migration.go b/internal/app/migration.go
index 51735cd9..745fecb3 100644
--- a/internal/app/migration.go
+++ b/internal/app/migration.go
@@ -138,7 +138,14 @@ func migrateOldAccounts(locations *locations.Locations, keychains *keychain.List
if err != nil {
return fmt.Errorf("failed to get helper: %w", err)
}
- keychain, _, err := keychain.NewKeychain(helper, "bridge", keychains.GetHelpers(), keychains.GetDefaultHelper())
+
+ keychain, _, err := keychain.NewKeychain(
+ helper, "bridge",
+ keychains.GetHelpers(),
+ keychains.GetDefaultHelper(),
+ 0,
+ make(map[string]bool),
+ )
if err != nil {
return fmt.Errorf("failed to create keychain: %w", err)
}
diff --git a/internal/app/migration_test.go b/internal/app/migration_test.go
index 7974412d..29f0aaa0 100644
--- a/internal/app/migration_test.go
+++ b/internal/app/migration_test.go
@@ -31,6 +31,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/cookies"
"github.com/ProtonMail/proton-bridge/v3/internal/legacy/credentials"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
@@ -85,7 +86,7 @@ func TestMigratePrefsToVaultWithoutKeys(t *testing.T) {
func TestKeychainMigration(t *testing.T) {
// Migration tested only for linux.
- if runtime.GOOS != "linux" {
+ if runtime.GOOS != platform.LINUX {
return
}
@@ -134,7 +135,13 @@ func TestKeychainMigration(t *testing.T) {
func TestUserMigration(t *testing.T) {
kcl := keychain.NewTestKeychainsList()
- kc, _, err := keychain.NewKeychain("mock", "bridge", kcl.GetHelpers(), kcl.GetDefaultHelper())
+ kc, _, err := keychain.NewKeychain(
+ "mock", "bridge",
+ kcl.GetHelpers(),
+ kcl.GetDefaultHelper(),
+ 0,
+ make(map[string]bool),
+ )
require.NoError(t, err)
require.NoError(t, kc.Put("brokenID", "broken"))
diff --git a/internal/app/vault.go b/internal/app/vault.go
index a0675645..f8e7d7a4 100644
--- a/internal/app/vault.go
+++ b/internal/app/vault.go
@@ -20,6 +20,7 @@ package app
import (
"crypto/sha256"
"encoding/hex"
+ "errors"
"fmt"
"path"
"runtime"
@@ -28,18 +29,20 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
+ "github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
)
-func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
+func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error {
logrus.Debug("Creating vault")
defer logrus.Debug("Vault stopped")
// Create the encVault.
- encVault, insecure, corrupt, err := newVault(reporter, locations, keychains, panicHandler)
+ encVault, insecure, corrupt, err := newVault(reporter, locations, keychains, featureFlags, panicHandler)
if err != nil {
return fmt.Errorf("could not create vault: %w", err)
}
@@ -61,7 +64,7 @@ func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keycha
return fn(encVault, insecure, corrupt != nil)
}
-func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, panicHandler async.PanicHandler) (*vault.Vault, bool, error, error) {
+func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler) (*vault.Vault, bool, error, error) {
vaultDir, err := locations.ProvideSettingsPath()
if err != nil {
return nil, false, nil, fmt.Errorf("could not get vault dir: %w", err)
@@ -75,7 +78,14 @@ func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychai
lastUsedHelper string
)
- if key, helper, err := loadVaultKey(vaultDir, keychains); err != nil {
+ if key, helper, err := loadVaultKey(vaultDir, keychains, featureFlags); err != nil {
+ if errors.Is(err, keychain.ErrPreferredKeychainNotAvailable) {
+ if err := vault.IncrementKeychainFailedAttemptCount(vaultDir); err != nil {
+ logrus.WithError(err).Error("Failed to increment failed keychain attempt count")
+ }
+ return &vault.Vault{}, false, nil, err
+ }
+
if reporter != nil {
if rerr := reporter.ReportMessageWithContext("Could not load/create vault key", map[string]any{
"keychainDefaultHelper": keychains.GetDefaultHelper(),
@@ -108,23 +118,38 @@ func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychai
}
// Remember the last successfully used keychain on Linux and store that as the user preference.
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == platform.LINUX {
if err := vault.SetHelper(vaultDir, lastUsedHelper); err != nil {
logrus.WithError(err).Error("Could not store last used keychain helper")
}
+
+ if err := vault.ResetFailedKeychainAttemptCount(vaultDir); err != nil {
+ logrus.WithError(err).Error("Could not reset and save failed keychain attempt count")
+ }
}
return userVault, insecure, corrupt, nil
}
// loadVaultKey - loads the key used to encrypt the vault alongside the keychain helper used to access it.
-func loadVaultKey(vaultDir string, keychains *keychain.List) (key []byte, keychainHelper string, err error) {
+func loadVaultKey(vaultDir string, keychains *keychain.List, featureFlags unleash.FeatureFlagStartupStore) (key []byte, keychainHelper string, err error) {
keychainHelper, err = vault.GetHelper(vaultDir)
if err != nil {
return nil, keychainHelper, fmt.Errorf("could not get keychain helper: %w", err)
}
- kc, keychainHelper, err := keychain.NewKeychain(keychainHelper, constants.KeyChainName, keychains.GetHelpers(), keychains.GetDefaultHelper())
+ keychainFailedAttemptCount, err := vault.GetKeychainFailedAttemptCount(vaultDir)
+ if err != nil {
+ return nil, keychainHelper, fmt.Errorf("could not get keychain failed attempt count: %w", err)
+ }
+
+ kc, keychainHelper, err := keychain.NewKeychain(
+ keychainHelper, constants.KeyChainName,
+ keychains.GetHelpers(),
+ keychains.GetDefaultHelper(),
+ keychainFailedAttemptCount,
+ featureFlags,
+ )
if err != nil {
return nil, keychainHelper, fmt.Errorf("could not create keychain: %w", err)
}
@@ -139,6 +164,12 @@ func loadVaultKey(vaultDir string, keychains *keychain.List) (key []byte, keycha
return key, keychainHelper, err
}
+ if keychain.ShouldRetryPreferredKeychain(featureFlags, keychainHelper) {
+ if keychainFailedAttemptCount < keychain.MaxFailedKeychainAttemptsLinux {
+ return nil, keychainHelper, keychain.PreferredKeychainRetryError(keychainFailedAttemptCount)
+ }
+ }
+
return nil, keychainHelper, fmt.Errorf("could not check for vault key: %w", err)
}
diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go
index 1fecc60d..fa30a9ea 100644
--- a/internal/bridge/bridge.go
+++ b/internal/bridge/bridge.go
@@ -42,6 +42,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/focus"
"github.com/ProtonMail/proton-bridge/v3/internal/identifier"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/sentry"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver"
@@ -687,7 +688,7 @@ func (bridge *Bridge) HasAPIConnection() bool {
// then we verify whether the gluon cache exists using the "new" username (provided by the DB path in this case)
// if so we modify the cache directory in the user vault.
func (bridge *Bridge) verifyUsernameChange() {
- if runtime.GOOS != "darwin" {
+ if runtime.GOOS != platform.MACOS {
return
}
diff --git a/internal/bridge/updates_test.go b/internal/bridge/updates_test.go
index 51149b16..dfd7e152 100644
--- a/internal/bridge/updates_test.go
+++ b/internal/bridge/updates_test.go
@@ -28,6 +28,7 @@ import (
"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/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/updater/versioncompare"
"github.com/elastic/go-sysinfo/types"
@@ -331,7 +332,7 @@ func Test_Update_CheckOSVersion_NoUpdate(t *testing.T) {
bridge.CheckForUpdates()
- if runtime.GOOS == "darwin" {
+ if runtime.GOOS == platform.MACOS {
require.Equal(t, events.UpdateNotAvailable{}, <-updateNotAvailableCh)
} else {
require.Equal(t, events.UpdateInstalled{
@@ -442,7 +443,7 @@ func Test_Update_CheckOSVersion_HasUpdate(t *testing.T) {
bridge.CheckForUpdates()
- if runtime.GOOS == "darwin" {
+ if runtime.GOOS == platform.MACOS {
require.Equal(t, events.UpdateInstalled{
Release: expectedUpdateRelease,
Silent: true,
diff --git a/internal/bridge/user_event_test.go b/internal/bridge/user_event_test.go
index 0da076a9..eec4439e 100644
--- a/internal/bridge/user_event_test.go
+++ b/internal/bridge/user_event_test.go
@@ -37,6 +37,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/bradenaw/juniper/stream"
"github.com/bradenaw/juniper/xslices"
@@ -77,7 +78,7 @@ func TestBridge_User_RefreshEvent(t *testing.T) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
syncCh, closeCh := chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
- if runtime.GOOS != "windows" {
+ if runtime.GOOS != platform.WINDOWS {
require.Equal(t, userID, (<-syncCh).UserID)
}
require.Equal(t, userID, (<-syncCh).UserID)
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index b554fc60..501093c0 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -21,6 +21,8 @@ package constants
import (
"fmt"
"runtime"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
)
const VendorName = "protonmail"
@@ -72,13 +74,13 @@ const (
// nolint:goconst
func getAPIOS() string {
switch runtime.GOOS {
- case "darwin":
+ case platform.MACOS:
return "macos"
- case "linux":
+ case platform.LINUX:
return "linux"
- case "windows":
+ case platform.WINDOWS:
return "windows"
default:
diff --git a/internal/frontend/cli/frontend.go b/internal/frontend/cli/frontend.go
index c8913d14..401ad5f8 100644
--- a/internal/frontend/cli/frontend.go
+++ b/internal/frontend/cli/frontend.go
@@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/pkg/restarter"
"github.com/abiosoft/ishell"
@@ -148,7 +149,7 @@ func New(
fe.AddCmd(dohCmd)
//goland:noinspection GoBoolExpressions
- if runtime.GOOS == "darwin" {
+ if runtime.GOOS == platform.MACOS {
// Apple Mail commands.
configureCmd := &ishell.Cmd{
Name: "configure-apple-mail",
@@ -165,7 +166,7 @@ func New(
}
//goland:noinspection GoBoolExpressions
- if runtime.GOOS == "darwin" {
+ if runtime.GOOS == platform.MACOS {
certCmd.AddCmd(&ishell.Cmd{
Name: "status",
Help: "Check if the TLS certificate used by Bridge is installed in the OS keychain",
diff --git a/internal/frontend/grpc/service.go b/internal/frontend/grpc/service.go
index 82cfc710..29aa8890 100644
--- a/internal/frontend/grpc/service.go
+++ b/internal/frontend/grpc/service.go
@@ -39,6 +39,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
@@ -685,5 +686,5 @@ func computeFileSocketPath() (string, error) {
// useFileSocket return true iff file socket should be used for the gRPC service.
func useFileSocket() bool {
//goland:noinspection GoBoolExpressions
- return runtime.GOOS != "windows"
+ return runtime.GOOS != platform.WINDOWS
}
diff --git a/internal/frontend/grpc/service_methods.go b/internal/frontend/grpc/service_methods.go
index 69d10efa..c1b721c9 100644
--- a/internal/frontend/grpc/service_methods.go
+++ b/internal/frontend/grpc/service_methods.go
@@ -32,6 +32,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/frontend/theme"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/kb"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
@@ -688,7 +689,7 @@ func (s *Service) SetDiskCachePath(_ context.Context, newPath *wrapperspb.String
path := newPath.Value
//goland:noinspection GoBoolExpressions
- if (runtime.GOOS == "windows") && (path[0] == '/') {
+ if (runtime.GOOS == platform.WINDOWS) && (path[0] == '/') {
path = path[1:]
}
diff --git a/internal/frontend/theme/theme.go b/internal/frontend/theme/theme.go
index 961e466f..d2289e4d 100644
--- a/internal/frontend/theme/theme.go
+++ b/internal/frontend/theme/theme.go
@@ -19,6 +19,8 @@ package theme
import (
"runtime"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
)
type Theme string
@@ -34,7 +36,7 @@ func IsAvailable(have Theme) bool {
func DefaultTheme() Theme {
switch runtime.GOOS {
- case "darwin", "windows":
+ case platform.MACOS, platform.WINDOWS:
return detectSystemTheme()
default:
return Light
diff --git a/internal/locations/locations.go b/internal/locations/locations.go
index 3928709b..a3876058 100644
--- a/internal/locations/locations.go
+++ b/internal/locations/locations.go
@@ -24,6 +24,7 @@ import (
"path/filepath"
"runtime"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/pkg/files"
"github.com/sirupsen/logrus"
)
@@ -90,7 +91,7 @@ func (l *Locations) getLicenseFilePath() string {
}
switch runtime.GOOS {
- case "linux":
+ case platform.LINUX:
// Most Linux distributions.
path := "/usr/share/doc/protonmail/" + l.configName + "/LICENSE"
if _, err := os.Stat(path); err == nil {
@@ -98,7 +99,7 @@ func (l *Locations) getLicenseFilePath() string {
}
// Arch distributions.
return "/usr/share/licenses/protonmail-" + l.configName + "/LICENSE"
- case "darwin": //nolint:goconst
+ case platform.MACOS: //nolint:goconst
path := filepath.Join(filepath.Dir(os.Args[0]), "..", "Resources", "LICENSE")
if _, err := os.Stat(path); err == nil {
return path
@@ -109,7 +110,7 @@ func (l *Locations) getLicenseFilePath() string {
// or may not work, depends where user installed the app and how
// user started the app.
return "/Applications/Proton Mail Bridge.app/Contents/Resources/LICENSE"
- case "windows":
+ case platform.WINDOWS:
path := filepath.Join(filepath.Dir(os.Args[0]), "LICENSE.txt")
if _, err := os.Stat(path); err == nil {
return path
@@ -206,6 +207,14 @@ func (l *Locations) ProvideUnleashCachePath() (string, error) {
return l.getUnleashCachePath(), nil
}
+func (l *Locations) ProvideUnleashStartupCachePath() (string, error) {
+ if err := os.MkdirAll(l.getUnleashStartupCachePath(), 0o700); err != nil {
+ return "", err
+ }
+
+ return l.getUnleashStartupCachePath(), nil
+}
+
func (l *Locations) getGluonCachePath() string {
return filepath.Join(l.userData, "gluon")
}
@@ -244,6 +253,10 @@ func (l *Locations) getNotificationsCachePath() string {
func (l *Locations) getUnleashCachePath() string { return filepath.Join(l.userCache, "unleash_cache") }
+func (l *Locations) getUnleashStartupCachePath() string {
+ return filepath.Join(l.userCache, "unleash_startup_cache")
+}
+
// Clear removes everything except the lock and update files.
func (l *Locations) Clear(except ...string) error {
return files.Remove(
diff --git a/internal/locations/provider.go b/internal/locations/provider.go
index c84b1939..3eec70d0 100644
--- a/internal/locations/provider.go
+++ b/internal/locations/provider.go
@@ -22,6 +22,8 @@ import (
"os"
"path/filepath"
"runtime"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
)
// Provider provides standard locations.
@@ -95,7 +97,7 @@ func (p *DefaultProvider) UserCache() string {
// This is necessary because os.UserDataDir() is not implemented by the Go standard library, sadly.
// On non-linux systems, it is the same as os.UserConfigDir().
func userDataDir() (string, error) {
- if runtime.GOOS != "linux" {
+ if runtime.GOOS != platform.LINUX {
return os.UserConfigDir()
}
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
new file mode 100644
index 00000000..d4d3b42c
--- /dev/null
+++ b/internal/platform/platform.go
@@ -0,0 +1,24 @@
+// 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 platform
+
+const (
+ MACOS = "darwin"
+ LINUX = "linux"
+ WINDOWS = "windows"
+)
diff --git a/internal/services/imapservice/conflicts.go b/internal/services/imapservice/conflicts.go
index 763e84f6..93f26f56 100644
--- a/internal/services/imapservice/conflicts.go
+++ b/internal/services/imapservice/conflicts.go
@@ -286,7 +286,7 @@ func (m *LabelConflictManager) NewInternalLabelConflictResolver(connectors []*Co
mailboxFetch: m.generateMailboxFetcher(connectors),
mailboxMessageCountFetch: m.generateMailboxMessageCountFetcher(connectors),
userLabelConflictResolver: m.NewConflictResolver(connectors),
- allowNonEmptyMailboxDeletion: m.featureFlagProvider.GetFlagValue(unleash.ItnternalLabelConflictNonEmptyMailboxDeletion),
+ allowNonEmptyMailboxDeletion: m.featureFlagProvider.GetFlagValue(unleash.InternalLabelConflictNonEmptyMailboxDeletion),
client: m.client,
reporter: m.reporter,
log: logrus.WithFields(logrus.Fields{
diff --git a/internal/unleash/service.go b/internal/unleash/service.go
index 51b196a9..3046d73a 100644
--- a/internal/unleash/service.go
+++ b/internal/unleash/service.go
@@ -38,14 +38,15 @@ var pollJitter = 2 * time.Minute //nolint:gochecknoglobals
const filename = "unleash_flags"
const (
- EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
- IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
- UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
- UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
- LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
- SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
- InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
- ItnternalLabelConflictNonEmptyMailboxDeletion = "InboxBridgeUnknownNonEmptyMailboxDeletion"
+ EventLoopNotificationDisabled = "InboxBridgeEventLoopNotificationDisabled"
+ IMAPAuthenticateCommandDisabled = "InboxBridgeImapAuthenticateCommandDisabled"
+ UserRemovalGluonDataCleanupDisabled = "InboxBridgeUserRemovalGluonDataCleanupDisabled"
+ UpdateUseNewVersionFileStructureDisabled = "InboxBridgeUpdateWithOsFilterDisabled"
+ LabelConflictResolverDisabled = "InboxBridgeLabelConflictResolverDisabled"
+ SMTPSubmissionRequestSentryReportDisabled = "InboxBridgeSmtpSubmissionRequestSentryReportDisabled"
+ InternalLabelConflictResolverDisabled = "InboxBridgeUnexpectedFoldersLabelsStartupFixupDisabled"
+ InternalLabelConflictNonEmptyMailboxDeletion = "InboxBridgeUnknownNonEmptyMailboxDeletion"
+ LinuxVaultPreferredKeychainNotAvailableRetryDisabled = "InboxBridgeLinuxVaultPreferredKeychainNotAvailableRetryDisabled"
)
type FeatureFlagValueProvider interface {
diff --git a/internal/unleash/startup.go b/internal/unleash/startup.go
new file mode 100644
index 00000000..180d9fba
--- /dev/null
+++ b/internal/unleash/startup.go
@@ -0,0 +1,132 @@
+// 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 unleash
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+
+ "github.com/Masterminds/semver/v3"
+ "github.com/ProtonMail/go-proton-api"
+ "github.com/ProtonMail/proton-bridge/v3/internal/constants"
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+)
+
+const startupCacheFilename = "unleash_startup_flags.json"
+
+var logger = logrus.WithField("pkg", "unleash-startup") //nolint:gochecknoglobals
+
+type FeatureFlagStartupStore map[string]bool
+
+func (f FeatureFlagStartupStore) GetFlagValue(key string) bool {
+ val, ok := f[key]
+ if !ok {
+ return false
+ }
+
+ return val
+}
+
+func newAPIOptions(
+ apiURL string,
+ version *semver.Version,
+) []proton.Option {
+ return []proton.Option{
+ proton.WithHostURL(apiURL),
+ proton.WithAppVersion(constants.AppVersion(version.Original())),
+ proton.WithLogger(logrus.WithField("pkg", "gpa/unleash-startup")),
+ proton.WithRetryCount(0),
+ }
+}
+
+func readStartupCacheFile(filepath string) (map[string]bool, error) {
+ ffStore := make(map[string]bool)
+ if filepath == "" {
+ return ffStore, nil
+ }
+
+ file, err := os.Open(filepath) //nolint:gosec
+ if err != nil {
+ return ffStore, err
+ }
+
+ defer func(file *os.File) {
+ if err := file.Close(); err != nil {
+ logger.WithError(err).Error("Unable to close cache file after read")
+ }
+ }(file)
+
+ if err := json.NewDecoder(file).Decode(&ffStore); err != nil {
+ return ffStore, err
+ }
+ return ffStore, nil
+}
+
+func saveStartupCacheFile(ffStore map[string]bool, filepath string) error {
+ if filepath == "" {
+ return nil
+ }
+
+ file, err := os.Create(filepath) //nolint:gosec
+ if err != nil {
+ return err
+ }
+
+ defer func(file *os.File) {
+ if err := file.Close(); err != nil {
+ logger.WithError(err).Error("Unable to close cache file after write")
+ }
+ }(file)
+
+ if err := json.NewEncoder(file).Encode(ffStore); err != nil {
+ return err
+ }
+ return nil
+}
+
+func GetStartupFeatureFlagsAndStore(apiURL string, curVersion *semver.Version, unleashCachePathProvider func() (string, error)) map[string]bool {
+ var cacheFilepath string
+ cacheDir, err := unleashCachePathProvider()
+ if err != nil {
+ logger.WithError(err).Warn("Unable to obtain feature flag cache filepath")
+ } else {
+ cacheFilepath = filepath.Clean(filepath.Join(cacheDir, startupCacheFilename))
+ }
+
+ ffStore, err := readStartupCacheFile(cacheFilepath)
+ if err != nil {
+ logger.WithError(err).Warn("An issue occurred when reading the cache file")
+ }
+
+ manager := proton.New(newAPIOptions(apiURL, curVersion)...)
+ featureFlagResult, err := manager.GetFeatures(context.Background(), uuid.New())
+ if err == nil {
+ ffStore = readResponseData(featureFlagResult)
+ } else {
+ logger.WithError(err).Warn("Failed to obtain feature flags from API")
+ }
+
+ if err := saveStartupCacheFile(ffStore, cacheFilepath); err != nil {
+ logger.WithError(err).Warn("An issue occurred when saving the cache file")
+ }
+
+ return ffStore
+}
diff --git a/internal/unleash/startup_test.go b/internal/unleash/startup_test.go
new file mode 100644
index 00000000..5a2cf604
--- /dev/null
+++ b/internal/unleash/startup_test.go
@@ -0,0 +1,156 @@
+// 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 unleash
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Masterminds/semver/v3"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadStartupCacheFile_Success(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "valid_cache")
+ file, err := os.Create(filePath)
+ require.NoError(t, err)
+
+ testData := map[string]bool{
+ "feature1": true,
+ "feature2": false,
+ }
+ err = json.NewEncoder(file).Encode(testData)
+ require.NoError(t, err)
+ err = file.Close()
+ require.NoError(t, err)
+
+ startupCache, err := readStartupCacheFile(filePath)
+ require.NoError(t, err)
+ require.Equal(t, testData, startupCache)
+}
+
+func TestReadStartupCacheFile_InvalidFilePath(t *testing.T) {
+ filePath := "badFilepath/hello"
+ startupCache, err := readStartupCacheFile(filePath)
+ require.Error(t, err)
+ require.Empty(t, startupCache)
+}
+
+func TestSaveStartupCacheFile_Success(t *testing.T) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "test_cache")
+
+ testData := map[string]bool{
+ "feature1": true,
+ "feature2": false,
+ "feature3": true,
+ }
+
+ err := saveStartupCacheFile(testData, filePath)
+ require.NoError(t, err)
+
+ savedData, err := readStartupCacheFile(filePath)
+ require.NoError(t, err)
+ require.Equal(t, testData, savedData)
+}
+
+func TestSaveStartupCacheFile_InvalidFilePath(t *testing.T) {
+ badFilePath := "/some_random_dir/hey/hello"
+
+ testData := map[string]bool{
+ "feature1": true,
+ "feature2": false,
+ }
+
+ err := saveStartupCacheFile(testData, badFilePath)
+ require.Error(t, err)
+}
+
+func TestGetStartupFeatureFlagsAndStore_FakeAPIURL(t *testing.T) {
+ apiURL := "https://example.com"
+ cacheProvider := func() (string, error) {
+ return t.TempDir(), nil
+ }
+
+ version, err := semver.NewVersion("3.99.99+test")
+ require.NoError(t, err)
+
+ featureFlags := GetStartupFeatureFlagsAndStore(apiURL, version, cacheProvider)
+ require.Empty(t, featureFlags)
+}
+
+func TestGetStartupFeatureFlagsAndStore_RealAPIURL(t *testing.T) {
+ apiURL := "https://mail-api.proton.me"
+ cacheProvider := func() (string, error) {
+ return t.TempDir(), nil
+ }
+
+ version, err := semver.NewVersion("3.99.99+test")
+ require.NoError(t, err)
+
+ featureFlags := GetStartupFeatureFlagsAndStore(apiURL, version, cacheProvider)
+ require.NotEmpty(t, featureFlags)
+}
+
+func TestGetStartupFeatureFlagsAndStore_FeatureFlagCacheRetention(t *testing.T) {
+ fakeAPIURL := "https://example.com"
+ realAPIURL := "https://mail-api.proton.me"
+
+ cacheDir := t.TempDir()
+ cacheProvider := func() (string, error) {
+ return cacheDir, nil
+ }
+
+ version, err := semver.NewVersion("3.99.99+test")
+ require.NoError(t, err)
+
+ featureFlags := GetStartupFeatureFlagsAndStore(realAPIURL, version, cacheProvider)
+ require.NotEmpty(t, featureFlags)
+
+ featureFlagsFromCache := GetStartupFeatureFlagsAndStore(fakeAPIURL, version, cacheProvider)
+ require.NotEmpty(t, featureFlagsFromCache)
+ require.Equal(t, featureFlags, featureFlagsFromCache)
+}
+
+func Test(t *testing.T) {
+ fakeAPIURL := "https://example.com"
+
+ tmpDir := t.TempDir()
+ cacheProvider := func() (string, error) {
+ return tmpDir, nil
+ }
+ filePath := filepath.Join(tmpDir, startupCacheFilename)
+
+ testData := map[string]bool{
+ "feature1": true,
+ "feature2": false,
+ "feature3": true,
+ }
+ err := saveStartupCacheFile(testData, filePath)
+ require.NoError(t, err)
+
+ version, err := semver.NewVersion("3.99.99+git")
+ require.NoError(t, err)
+
+ featureFlagsFromCache := GetStartupFeatureFlagsAndStore(fakeAPIURL, version, cacheProvider)
+ require.NotEmpty(t, featureFlagsFromCache)
+ require.Equal(t, testData, featureFlagsFromCache)
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 9b93ef9a..b704e9f9 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -26,6 +26,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gopenpgp/v2/crypto"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/ProtonMail/proton-bridge/v3/internal/versioner"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@@ -194,7 +195,7 @@ func (u *Updater) getVersionFileURLLegacy() string {
// - https://protonmail.com/download/darwin/universal/v1/version.json
func (u *Updater) getVersionFileURL() string {
switch u.platform {
- case "darwin":
+ case platform.MACOS:
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/useragent/platform.go b/internal/useragent/platform.go
index 296ce3ec..3e351ca5 100644
--- a/internal/useragent/platform.go
+++ b/internal/useragent/platform.go
@@ -22,6 +22,7 @@ import (
"strings"
"github.com/Masterminds/semver/v3"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
)
// IsCatalinaOrNewer checks whether the host is macOS Catalina 10.15.x or higher.
@@ -44,7 +45,7 @@ func getMinBigSur() *semver.Version { return semver.MustParse("20.0.0") }
func getMinVentura() *semver.Version { return semver.MustParse("22.0.0") }
func isThisDarwinNewerOrEqual(minVersion *semver.Version) bool {
- if runtime.GOOS != "darwin" {
+ if runtime.GOOS != platform.MACOS {
return false
}
diff --git a/internal/vault/helper.go b/internal/vault/helper.go
index 7c6eabea..90ee417d 100644
--- a/internal/vault/helper.go
+++ b/internal/vault/helper.go
@@ -76,6 +76,34 @@ func SetHelper(vaultDir, helper string) error {
return settings.Save(vaultDir)
}
+func GetKeychainFailedAttemptCount(vaultDir string) (int, error) {
+ keychainState, err := LoadKeychainState(vaultDir)
+ if err != nil {
+ return 0, err
+ }
+ return keychainState.FailedAttempts, nil
+}
+
+func IncrementKeychainFailedAttemptCount(vaultDir string) error {
+ keychainState, err := LoadKeychainState(vaultDir)
+ if err != nil {
+ return err
+ }
+
+ keychainState.FailedAttempts++
+ return keychainState.Save(vaultDir)
+}
+
+// ResetFailedKeychainAttemptCount - resets the failed keychain attempt count, and stores the data in the appropriate helper file.
+func ResetFailedKeychainAttemptCount(vaultDir string) error {
+ keychainState, err := LoadKeychainState(vaultDir)
+ if err != nil {
+ return err
+ }
+
+ return keychainState.ResetAndSave(vaultDir)
+}
+
func GetVaultKey(kc *keychain.Keychain) ([]byte, error) {
_, keyEnc, err := kc.Get(vaultSecretName)
if err != nil {
diff --git a/internal/vault/keychain_settings.go b/internal/vault/keychain_settings.go
index 072ccc0d..209a623e 100644
--- a/internal/vault/keychain_settings.go
+++ b/internal/vault/keychain_settings.go
@@ -17,15 +17,7 @@
package vault
-import (
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/sirupsen/logrus"
-)
+import "github.com/ProtonMail/proton-bridge/v3/internal/vault/storage"
const keychainSettingsFileName = "keychain.json"
@@ -35,40 +27,15 @@ type KeychainSettings struct {
DisableTest bool // Is the keychain test on startup disabled?
}
+var keychainSettingsFile = storage.NewJSONStorageFile[KeychainSettings](keychainSettingsFileName, "keychain settings") //nolint:gochecknoglobals
+
// LoadKeychainSettings load keychain settings from the vaultDir folder, or returns a default one if the file
// does not exists or is invalid.
func LoadKeychainSettings(vaultDir string) (KeychainSettings, error) {
- path := filepath.Join(vaultDir, keychainSettingsFileName)
- bytes, err := os.ReadFile(path) //nolint:gosec
- if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- logrus.
- WithFields(logrus.Fields{"pkg": "vault", "path": path}).
- Trace("Keychain settings file does not exists, default values will be used")
- return KeychainSettings{}, nil
- }
- return KeychainSettings{}, err
- }
-
- var result KeychainSettings
- if err := json.Unmarshal(bytes, &result); err != nil {
- return KeychainSettings{}, fmt.Errorf("keychain settings file is invalid settings: %w", err)
- }
-
- return result, nil
+ return keychainSettingsFile.Load(vaultDir)
}
// Save saves the keychain settings in a file in the vaultDir folder.
func (k KeychainSettings) Save(vaultDir string) error {
- bytes, err := json.MarshalIndent(k, "", " ")
- if err != nil {
- return err
- }
-
- if err = os.MkdirAll(vaultDir, 0o700); err != nil {
- return err
- }
-
- path := filepath.Join(vaultDir, keychainSettingsFileName)
- return os.WriteFile(path, bytes, 0o600)
+ return keychainSettingsFile.Save(vaultDir, k)
}
diff --git a/internal/vault/keychain_state.go b/internal/vault/keychain_state.go
new file mode 100644
index 00000000..3f138e38
--- /dev/null
+++ b/internal/vault/keychain_state.go
@@ -0,0 +1,53 @@
+// 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 vault
+
+import (
+ "runtime"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
+ "github.com/ProtonMail/proton-bridge/v3/internal/vault/storage"
+)
+
+const keychainStateFileName = "keychain_state.json"
+
+type KeychainState struct {
+ FailedAttempts int
+}
+
+var keychainStateFile = storage.NewJSONStorageFile[KeychainState](keychainStateFileName, "keychain state") //nolint:gochecknoglobals
+
+func LoadKeychainState(vaultDir string) (KeychainState, error) {
+ if runtime.GOOS != platform.LINUX {
+ return KeychainState{}, nil
+ }
+ return keychainStateFile.Load(vaultDir)
+}
+
+func (k KeychainState) Save(vaultDir string) error {
+ if runtime.GOOS != platform.LINUX {
+ return nil
+ }
+
+ return keychainStateFile.Save(vaultDir, k)
+}
+
+func (k KeychainState) ResetAndSave(vaultDir string) error {
+ k.FailedAttempts = 0
+ return k.Save(vaultDir)
+}
diff --git a/internal/vault/keychain_state_test.go b/internal/vault/keychain_state_test.go
new file mode 100644
index 00000000..0bcd692e
--- /dev/null
+++ b/internal/vault/keychain_state_test.go
@@ -0,0 +1,75 @@
+// 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 vault
+
+import (
+ "runtime"
+ "testing"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
+ "github.com/stretchr/testify/require"
+)
+
+func TestKeychainState(t *testing.T) {
+ dir := t.TempDir()
+
+ // Load a non-existing keychain state file. It should return the defaults if it does not exist and no error will be thrown.
+ keychainState, err := LoadKeychainState(dir)
+ require.NoError(t, err)
+ require.Equal(t, KeychainState{}, keychainState)
+
+ // Increment the failed attempt count. The function call will save the data to the file.
+ err = IncrementKeychainFailedAttemptCount(dir)
+ require.NoError(t, err)
+
+ // Load the state from the now existing file. We isolate the behaviour of the helper to Linux.
+ // Thus, a nil state is expected on other OS'.
+ keychainState, err = LoadKeychainState(dir)
+ require.NoError(t, err)
+ if runtime.GOOS == platform.LINUX {
+ require.Equal(t, KeychainState{
+ FailedAttempts: 1,
+ }, keychainState)
+ } else {
+ require.Equal(t, KeychainState{}, keychainState)
+ }
+
+ // Increment again.
+ err = IncrementKeychainFailedAttemptCount(dir)
+ require.NoError(t, err)
+
+ // Same thing, we only expect linux to have data.
+ keychainState, err = LoadKeychainState(dir)
+ require.NoError(t, err)
+ if runtime.GOOS == platform.LINUX {
+ require.Equal(t, KeychainState{
+ FailedAttempts: 2,
+ }, keychainState)
+ } else {
+ require.Equal(t, KeychainState{}, keychainState)
+ }
+
+ // Reset the failed attempt count.
+ err = ResetFailedKeychainAttemptCount(dir)
+ require.NoError(t, err)
+
+ // All OS' states should match in this case.
+ keychainState, err = LoadKeychainState(dir)
+ require.NoError(t, err)
+ require.Equal(t, KeychainState{}, keychainState)
+}
diff --git a/internal/vault/storage/storage.go b/internal/vault/storage/storage.go
new file mode 100644
index 00000000..f946219b
--- /dev/null
+++ b/internal/vault/storage/storage.go
@@ -0,0 +1,75 @@
+// 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 storage
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/sirupsen/logrus"
+)
+
+type JSONFile[T any] struct {
+ fileName string
+ fileType string
+}
+
+func NewJSONStorageFile[T any](fileName, fileType string) *JSONFile[T] {
+ return &JSONFile[T]{
+ fileName: fileName,
+ fileType: fileType,
+ }
+}
+
+func (jf *JSONFile[T]) Load(vaultDir string) (T, error) {
+ var result T
+ path := filepath.Join(vaultDir, jf.fileName)
+ bytes, err := os.ReadFile(path) //nolint:gosec
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ logrus.
+ WithFields(logrus.Fields{"pkg": "vault", "path": path}).
+ Tracef("%s file does not exists, default values will be used", jf.fileType)
+ return result, nil
+ }
+ return result, err
+ }
+
+ if err := json.Unmarshal(bytes, &result); err != nil {
+ return result, fmt.Errorf("%s file has invalid data: %w", jf.fileType, err)
+ }
+
+ return result, nil
+}
+
+func (jf *JSONFile[T]) Save(vaultDir string, data T) error {
+ bytes, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return err
+ }
+
+ if err = os.MkdirAll(vaultDir, 0o700); err != nil {
+ return err
+ }
+
+ path := filepath.Join(vaultDir, jf.fileName)
+ return os.WriteFile(path, bytes, 0o600)
+}
diff --git a/internal/versioner/util.go b/internal/versioner/util.go
index d3ae0550..79163eea 100644
--- a/internal/versioner/util.go
+++ b/internal/versioner/util.go
@@ -20,6 +20,8 @@ package versioner
import (
"os"
"runtime"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
)
// fileExists returns whether the given file exists.
@@ -30,7 +32,7 @@ func fileExists(path string) bool {
// fileIsExecutable returns the given filepath and true if it exists.
func fileIsExecutable(path string) bool {
- if runtime.GOOS == "windows" {
+ if runtime.GOOS == platform.WINDOWS {
return true
}
diff --git a/pkg/keychain/keychain.go b/pkg/keychain/keychain.go
index 64029e05..0b090d43 100644
--- a/pkg/keychain/keychain.go
+++ b/pkg/keychain/keychain.go
@@ -27,6 +27,8 @@ import (
"time"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
+ "github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus"
)
@@ -37,6 +39,10 @@ type helperConstructor func(string) (credentials.Helper, error)
// Version is the keychain data version.
const Version = "k11"
+// MaxFailedKeychainAttemptsLinux defines the number of failed attempts allowed for the preferred keychain on Linux.
+// Since counting starts at 0, a value of 2 allows for 3 total attempts.
+const MaxFailedKeychainAttemptsLinux = 2
+
var (
// ErrNoKeychain indicates that no suitable keychain implementation could be loaded.
ErrNoKeychain = errors.New("no keychain") //nolint:gochecknoglobals
@@ -45,6 +51,8 @@ var (
ErrMacKeychainRebuild = errors.New("keychain error -25293")
ErrKeychainNoItem = errors.New("no such keychain item")
+
+ ErrPreferredKeychainNotAvailable = errors.New("preferred keychain is not available or usable")
)
func IsErrKeychainNoItem(err error) bool {
@@ -82,15 +90,39 @@ func (kcl *List) GetDefaultHelper() string {
return kcl.defaultHelper
}
+func PreferredKeychainRetryError(attemptCount int) error {
+ return fmt.Errorf("%w, %d attempts remaining till vault reset", ErrPreferredKeychainNotAvailable, MaxFailedKeychainAttemptsLinux-attemptCount)
+}
+
+func ShouldRetryPreferredKeychain(featureFlags unleash.FeatureFlagStartupStore, preferredKeychain string) bool {
+ return !featureFlags.GetFlagValue(unleash.LinuxVaultPreferredKeychainNotAvailableRetryDisabled) &&
+ runtime.GOOS == platform.LINUX && preferredKeychain != ""
+}
+
// NewKeychain creates a new native keychain. It also returns the keychain helper used to access the keychain.
-func NewKeychain(preferred, keychainName string, helpers Helpers, defaultHelper string) (kc *Keychain, usedKeychainHelper string, err error) {
+func NewKeychain(
+ preferred, keychainName string,
+ helpers Helpers,
+ defaultHelper string,
+ keychainFailedAttemptCount int,
+ featureFlags unleash.FeatureFlagStartupStore,
+) (kc *Keychain, usedKeychainHelper string, err error) {
// There must be at least one keychain helper available.
if len(helpers) < 1 {
return nil, "", ErrNoKeychain
}
// If the preferred keychain is unsupported, fallback to the default one.
+ // For linux, keep on exiting early before wiping the vault until we've exceeded the allowed retry count.
if _, ok := helpers[preferred]; !ok {
+ if ShouldRetryPreferredKeychain(featureFlags, preferred) {
+ if keychainFailedAttemptCount < MaxFailedKeychainAttemptsLinux {
+ return nil, "", PreferredKeychainRetryError(keychainFailedAttemptCount)
+ }
+
+ logrus.Errorf("%s, max attempts have been exceeded, resetting vault", ErrPreferredKeychainNotAvailable)
+ }
+
preferred = defaultHelper
}
@@ -242,7 +274,7 @@ func isUsable(helper credentials.Helper, err error) bool { //nolint:unused
func getTestCredentials() *credentials.Credentials { //nolint:unused
// On macOS, a handful of users experience failures of the test credentials.
- if runtime.GOOS == "darwin" {
+ if runtime.GOOS == platform.MACOS {
return &credentials.Credentials{
ServerURL: hostURL(constants.KeyChainName) + fmt.Sprintf("/check_%v", time.Now().UTC().UnixMicro()),
Username: "", // username is ignored on macOS, it's extracted from splitting the server URL
diff --git a/pkg/keychain/keychain_test.go b/pkg/keychain/keychain_test.go
index 05f66a83..f7709a30 100644
--- a/pkg/keychain/keychain_test.go
+++ b/pkg/keychain/keychain_test.go
@@ -120,7 +120,12 @@ func TestIsErrKeychainNoItem(t *testing.T) {
helpers := NewList().GetHelpers()
for helperName := range helpers {
- kc, _, err := NewKeychain(helperName, "bridge-test", helpers, helperName)
+ kc, _, err := NewKeychain(
+ helperName, "bridge-test",
+ helpers, helperName,
+ 0,
+ make(map[string]bool),
+ )
r.NoError(err)
_, _, err = kc.Get("non-existing")
diff --git a/pkg/tar/tar.go b/pkg/tar/tar.go
index 1c59c9cc..cb3414fc 100644
--- a/pkg/tar/tar.go
+++ b/pkg/tar/tar.go
@@ -25,6 +25,7 @@ import (
"path/filepath"
"runtime"
+ "github.com/ProtonMail/proton-bridge/v3/internal/platform"
"github.com/sirupsen/logrus"
)
@@ -91,7 +92,7 @@ func UntarToDir(r io.Reader, dir string) error {
if _, err := io.Copy(f, lr); err != nil {
return err
}
- if runtime.GOOS != "windows" {
+ if runtime.GOOS != platform.WINDOWS {
if err := f.Chmod(header.FileInfo().Mode()); err != nil {
return err
}
diff --git a/utils/bridge-rollout/bridge-rollout.go b/utils/bridge-rollout/bridge-rollout.go
index a0a31ace..156e2e06 100644
--- a/utils/bridge-rollout/bridge-rollout.go
+++ b/utils/bridge-rollout/bridge-rollout.go
@@ -62,7 +62,7 @@ func main() {
func getRollout(_ *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
- return app.WithVault(nil, locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error {
+ return app.WithVault(nil, locations, keychains, make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error {
fmt.Println(vault.GetUpdateRollout())
return nil
})
@@ -73,7 +73,7 @@ func getRollout(_ *cli.Context) error {
func setRollout(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
- return app.WithVault(nil, locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error {
+ return app.WithVault(nil, locations, keychains, make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error {
clamped := max(0.0, min(1.0, c.Float64("value")))
if err := vault.SetUpdateRollout(clamped); err != nil {
return err
diff --git a/utils/vault-editor/main.go b/utils/vault-editor/main.go
index 37622684..90f05c14 100644
--- a/utils/vault-editor/main.go
+++ b/utils/vault-editor/main.go
@@ -27,6 +27,7 @@ import (
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
+ "github.com/ProtonMail/proton-bridge/v3/internal/unleash"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/urfave/cli/v2"
@@ -52,7 +53,7 @@ func main() {
func readAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
- return app.WithVault(nil, locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
+ return app.WithVault(nil, locations, keychains, make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil {
return fmt.Errorf("failed to write vault: %w", err)
}
@@ -66,7 +67,7 @@ func readAction(c *cli.Context) error {
func writeAction(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
- return app.WithVault(nil, locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
+ return app.WithVault(nil, locations, keychains, make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read vault: %w", err)