From ae5d388ea3116cc7a2d23c95f106d305cd1aa65c Mon Sep 17 00:00:00 2001 From: tdawe <90407556+tdawe1@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:40:29 +0900 Subject: [PATCH] protondrive: align backend with newer Proton SDK stack send SDK-era app headers for move and upload compatibility --- backend/protondrive/protondrive.go | 103 +++++++++++++++++- .../protondrive/protondrive_internal_test.go | 55 ++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 backend/protondrive/protondrive_internal_test.go diff --git a/backend/protondrive/protondrive.go b/backend/protondrive/protondrive.go index df465f9bb..1b76e9d73 100644 --- a/backend/protondrive/protondrive.go +++ b/backend/protondrive/protondrive.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "path" + "regexp" "strings" "time" + "github.com/coreos/go-semver/semver" protonDriveAPI "github.com/rclone/Proton-API-Bridge" "github.com/rclone/go-proton-api" @@ -49,6 +51,7 @@ const ( var ( errCanNotUploadFileWithUnknownSize = errors.New("proton Drive can't upload files with unknown size") errCanNotPurgeRootDirectory = errors.New("can't purge root directory") + protonDriveInvalidVersionChars = regexp.MustCompile(`[^0-9A-Za-z.+-]+`) // for the auth/deauth handler _mapper configmap.Mapper @@ -151,11 +154,12 @@ size, will fail to operate properly`, Name: "app_version", Help: `The app version string -The app version string indicates the client that is currently performing -the API request. This information is required and will be sent with every -API request.`, + The app version string identifies the client that is currently performing + the API request. Third-party Proton Drive integrations should use the form + external-drive-@. If this option is left empty, rclone + derives a compliant value from its own version. This value is sent with + every API request; the option itself is optional.`, Advanced: true, - Default: "macos-drive@1.0.0-alpha.1+rclone", }, { Name: "replace_existing_draft", Help: `Create a new revision when filename conflict is detected @@ -328,9 +332,100 @@ func deAuthHandler() { clearConfigMap(_mapper) } +func protonDriveAppVersionFromRcloneVersion(version string) string { + const fallback = "external-drive-rclone@1.0.0-stable" + + version = strings.TrimSpace(strings.TrimPrefix(version, "v")) + version = protonDriveInvalidVersionChars.ReplaceAllString(version, "-") + if version == "" { + return fallback + } + + parsedVersion, err := semver.NewVersion(version) + if err != nil { + return fallback + } + + appVersion := fmt.Sprintf( + "external-drive-rclone@%d.%d.%d", + parsedVersion.Major, + parsedVersion.Minor, + parsedVersion.Patch, + ) + + metadataParts := protonDriveMetadataParts(parsedVersion.Metadata) + preRelease := strings.ToLower(string(parsedVersion.PreRelease)) + + switch { + case preRelease == "": + return protonDriveJoinAppVersion(appVersion, "-stable", metadataParts) + case preRelease == "dev": + return protonDriveJoinAppVersion(appVersion, "-dev", metadataParts) + case preRelease == "beta" || strings.HasPrefix(preRelease, "beta."): + betaSuffix, betaMetadataParts := protonDriveBetaSuffixAndMetadata(preRelease) + return protonDriveJoinAppVersion(appVersion, betaSuffix, append(betaMetadataParts, metadataParts...)) + default: + return fallback + } +} + +func protonDriveMetadataParts(metadata string) []string { + if metadata == "" { + return nil + } + + parts := strings.Split(metadata, ".") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + out = append(out, part) + } + } + return out +} + +func protonDriveBetaSuffixAndMetadata(preRelease string) (suffix string, metadata []string) { + suffix = "-beta" + remainder := strings.TrimPrefix(strings.TrimPrefix(preRelease, "beta"), ".") + if remainder == "" { + return suffix, nil + } + + parts := protonDriveMetadataParts(remainder) + i := 0 + for i < len(parts) && isDecimalString(parts[i]) { + suffix += "." + parts[i] + i++ + } + return suffix, parts[i:] +} + +func protonDriveJoinAppVersion(appVersion, suffix string, metadata []string) string { + version := appVersion + suffix + if len(metadata) != 0 { + version += "+" + strings.Join(metadata, ".") + } + return version +} + +func isDecimalString(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + func newProtonDrive(ctx context.Context, f *Fs, opt *Options, m configmap.Mapper) (*protonDriveAPI.ProtonDrive, error) { config := protonDriveAPI.NewDefaultConfig() config.AppVersion = opt.AppVersion + if config.AppVersion == "" { + config.AppVersion = protonDriveAppVersionFromRcloneVersion(fs.Version) + } config.UserAgent = f.ci.UserAgent // opt.UserAgent config.ReplaceExistingDraft = opt.ReplaceExistingDraft diff --git a/backend/protondrive/protondrive_internal_test.go b/backend/protondrive/protondrive_internal_test.go new file mode 100644 index 000000000..7c6751244 --- /dev/null +++ b/backend/protondrive/protondrive_internal_test.go @@ -0,0 +1,55 @@ +package protondrive + +import ( + "regexp" + "testing" +) + +var protonDriveAppVersionPattern = regexp.MustCompile(`(?i)^external-drive(-[a-z_]+)+@[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?-((stable|beta|RC|alpha)(([.-]?\d+)*)?)?([.-]?dev)?(\+.*)?$`) + +func TestProtonDriveAppVersionFromRcloneVersion(t *testing.T) { + testCases := []struct { + name string + rcloneVersion string + want string + }{ + { + name: "release", + rcloneVersion: "v1.73.5", + want: "external-drive-rclone@1.73.5-stable", + }, + { + name: "dev build", + rcloneVersion: "v1.74.0-DEV", + want: "external-drive-rclone@1.74.0-dev", + }, + { + name: "beta build with extra metadata", + rcloneVersion: "v1.74.0-beta.9519.990f33f2a.fix-protondrive-sdk-2026", + want: "external-drive-rclone@1.74.0-beta.9519+990f33f2a.fix-protondrive-sdk-2026", + }, + { + name: "beta build with unsanitized branch name", + rcloneVersion: "v1.74.0-beta.9519.990f33f2a.fix/protondrive-sdk-2026", + want: "external-drive-rclone@1.74.0-beta.9519+990f33f2a.fix-protondrive-sdk-2026", + }, + { + name: "invalid version falls back to stable", + rcloneVersion: "not-a-version", + want: "external-drive-rclone@1.0.0-stable", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := protonDriveAppVersionFromRcloneVersion(testCase.rcloneVersion) + + if got != testCase.want { + t.Fatalf("unexpected app version: got %q, want %q", got, testCase.want) + } + if !protonDriveAppVersionPattern.MatchString(got) { + t.Fatalf("app version %q does not match Proton pattern", got) + } + }) + } +}