From 8cd43746a3db05b1c62c8702eb679280d2ba35b0 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Wed, 30 Sep 2026 12:06:42 +0100 Subject: [PATCH] selfupdate: fix --version X.Y depending on ./ in the download site links To find the latest patch release of a minor version, for example `rclone selfupdate --version 1.75`, selfupdate searched the listing of downloads.rclone.org for href="./vX.Y.Z/". The leading ./ is a detail of how Caddy's file server writes its links. A listing which linked to the same directories as "vX.Y.Z/", which is an equally valid relative URL, made selfupdate fail with "could not find the minor release". This makes the ./ optional in the pattern, so selfupdate no longer depends on which program wrote the listing, and takes the version from a capture group rather than from fixed offsets into the match. The listings written by rclone index now include the ./ for the benefit of rclone versions without this fix, so this is to remove the dependency for the future. --- cmd/selfupdate/selfupdate.go | 24 ++++++++++++----- cmd/selfupdate/selfupdate_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/cmd/selfupdate/selfupdate.go b/cmd/selfupdate/selfupdate.go index 0f5c9694a..26519fba1 100644 --- a/cmd/selfupdate/selfupdate.go +++ b/cmd/selfupdate/selfupdate.go @@ -133,18 +133,28 @@ func GetVersion(ctx context.Context, beta bool, version string) (newVersion, sit if err != nil { return "", siteURL, fmt.Errorf("failed to get list of releases: %w", err) } - reSubver := fmt.Sprintf(`href="\./%s\.\d+/"`, regexp.QuoteMeta(newVersion)) - allSubvers := regexp.MustCompile(reSubver).FindAllString(string(html), -1) - if allSubvers == nil { - return "", siteURL, errors.New("could not find the minor release") + newVersion, err = latestMicroRelease(html, newVersion) + if err != nil { + return "", siteURL, err } - // Use the fact that releases in the index are sorted by date - lastSubver := allSubvers[len(allSubvers)-1] - newVersion = lastSubver[8 : len(lastSubver)-2] } return } +// latestMicroRelease returns the latest micro release of the +// major.minor release version linked to from the HTML index of +// releases. +func latestMicroRelease(html []byte, version string) (string, error) { + // The links may or may not start with ./ depending on what made the index + reSubver := fmt.Sprintf(`href="(?:\./)?(%s\.\d+)/"`, regexp.QuoteMeta(version)) + allSubvers := regexp.MustCompile(reSubver).FindAllSubmatch(html, -1) + if allSubvers == nil { + return "", errors.New("could not find the minor release") + } + // Use the fact that releases in the index are sorted by date + return string(allSubvers[len(allSubvers)-1][1]), nil +} + // InstallUpdate performs rclone self-update func InstallUpdate(ctx context.Context, opt *Options) error { // Find the latest release number diff --git a/cmd/selfupdate/selfupdate_test.go b/cmd/selfupdate/selfupdate_test.go index 3c22ee6ef..5161ef96a 100644 --- a/cmd/selfupdate/selfupdate_test.go +++ b/cmd/selfupdate/selfupdate_test.go @@ -45,6 +45,50 @@ func TestGetVersion(t *testing.T) { assert.Equal(t, "v1.52.3", resultVer) } +func TestLatestMicroRelease(t *testing.T) { + for _, test := range []struct { + name string + html string + want string + }{ + { + name: "caddy", + html: `v1.51.0/ +v1.52.0/ +v1.52.3/ +v1.53.0/ +v1.52-DEV/ +rclone`, + want: "v1.52.3", + }, + { + name: "no prefix", + html: `v1.51.0/ +v1.52.0/ +v1.52.3/ +v1.53.0/`, + want: "v1.52.3", + }, + { + name: "other prefix", + html: `v1.52.0/ +v1.52.1/ +v1.52.2/ +v1x52.3/`, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := latestMicroRelease([]byte(test.html), "v1.52") + if test.want == "" { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + func TestInstallOnLinux(t *testing.T) { testy.SkipUnreliable(t) if runtime.GOOS != "linux" {