mirror of
https://github.com/rclone/rclone.git
synced 2026-10-09 14:39:28 -04:00
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.
(cherry picked from commit 8cd43746a3)
511 lines
15 KiB
Go
511 lines
15 KiB
Go
//go:build !noselfupdate
|
|
|
|
// Package selfupdate provides the selfupdate command.
|
|
package selfupdate
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/rclone/rclone/cmd"
|
|
"github.com/rclone/rclone/cmd/cmount"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/config/flags"
|
|
"github.com/rclone/rclone/fs/fshttp"
|
|
"github.com/rclone/rclone/lib/buildinfo"
|
|
"github.com/rclone/rclone/lib/random"
|
|
"github.com/spf13/cobra"
|
|
|
|
versionCmd "github.com/rclone/rclone/cmd/version"
|
|
)
|
|
|
|
//go:embed selfupdate.md
|
|
var selfUpdateHelp string
|
|
|
|
// Options contains options for the self-update command
|
|
type Options struct {
|
|
Check bool
|
|
Output string // output path
|
|
Beta bool // mutually exclusive with Stable (false means "stable")
|
|
Stable bool // mutually exclusive with Beta
|
|
Version string
|
|
Package string // package format: zip, deb, rpm (empty string means "zip")
|
|
}
|
|
|
|
// Opt is options set via command line
|
|
var Opt = Options{}
|
|
|
|
func init() {
|
|
cmd.Root.AddCommand(cmdSelfUpdate)
|
|
cmdFlags := cmdSelfUpdate.Flags()
|
|
flags.BoolVarP(cmdFlags, &Opt.Check, "check", "", Opt.Check, "Check for latest release, do not download", "")
|
|
flags.StringVarP(cmdFlags, &Opt.Output, "output", "", Opt.Output, "Save the downloaded binary at a given path (default: replace running binary)", "")
|
|
flags.BoolVarP(cmdFlags, &Opt.Stable, "stable", "", Opt.Stable, "Install stable release (this is the default)", "")
|
|
flags.BoolVarP(cmdFlags, &Opt.Beta, "beta", "", Opt.Beta, "Install beta release", "")
|
|
flags.StringVarP(cmdFlags, &Opt.Version, "version", "", Opt.Version, "Install the given rclone version (default: latest)", "")
|
|
flags.StringVarP(cmdFlags, &Opt.Package, "package", "", Opt.Package, "Package format: zip|deb|rpm (default: zip)", "")
|
|
}
|
|
|
|
var cmdSelfUpdate = &cobra.Command{
|
|
Use: "selfupdate",
|
|
Aliases: []string{"self-update"},
|
|
Short: `Update the rclone binary.`,
|
|
Long: strings.TrimSpace(selfUpdateHelp),
|
|
Annotations: map[string]string{
|
|
"versionIntroduced": "v1.55",
|
|
},
|
|
Run: func(command *cobra.Command, args []string) {
|
|
ctx := context.Background()
|
|
cmd.CheckArgs(0, 0, command, args)
|
|
if Opt.Package == "" {
|
|
Opt.Package = "zip"
|
|
}
|
|
gotActionFlags := Opt.Stable || Opt.Beta || Opt.Output != "" || Opt.Version != "" || Opt.Package != "zip"
|
|
if Opt.Check && !gotActionFlags {
|
|
versionCmd.CheckVersion(ctx)
|
|
return
|
|
}
|
|
if Opt.Package != "zip" {
|
|
if Opt.Package != "deb" && Opt.Package != "rpm" {
|
|
fs.Fatalf(nil, "--package should be one of zip|deb|rpm")
|
|
}
|
|
if runtime.GOOS != "linux" {
|
|
fs.Fatalf(nil, ".deb and .rpm packages are supported only on Linux")
|
|
} else if os.Geteuid() != 0 && !Opt.Check {
|
|
fs.Fatalf(nil, ".deb and .rpm must be installed by root")
|
|
}
|
|
if Opt.Output != "" && !Opt.Check {
|
|
fmt.Println("Warning: --output is ignored with --package deb|rpm")
|
|
}
|
|
}
|
|
if err := InstallUpdate(context.Background(), &Opt); err != nil {
|
|
fs.Fatalf(nil, "Error: %v", err)
|
|
}
|
|
},
|
|
}
|
|
|
|
// GetVersion can get the latest release number from the download site
|
|
// or massage a stable release number - prepend semantic "v" prefix
|
|
// or find the latest micro release for a given major.minor release.
|
|
// Note: this will not be applied to beta releases.
|
|
func GetVersion(ctx context.Context, beta bool, version string) (newVersion, siteURL string, err error) {
|
|
siteURL = "https://downloads.rclone.org"
|
|
if beta {
|
|
siteURL = "https://beta.rclone.org"
|
|
}
|
|
|
|
if version == "" {
|
|
// Request the latest release number from the download site
|
|
_, newVersion, _, err = versionCmd.GetVersion(ctx, siteURL+"/version.txt")
|
|
return
|
|
}
|
|
|
|
newVersion = version
|
|
if version[0] != 'v' {
|
|
newVersion = "v" + version
|
|
}
|
|
if beta {
|
|
return
|
|
}
|
|
|
|
if valid, _ := regexp.MatchString(`^v\d+\.\d+(\.\d+)?$`, newVersion); !valid {
|
|
return "", siteURL, errors.New("invalid semantic version")
|
|
}
|
|
|
|
// Find the latest stable micro release
|
|
if strings.Count(newVersion, ".") == 1 {
|
|
html, err := downloadFile(ctx, siteURL)
|
|
if err != nil {
|
|
return "", siteURL, fmt.Errorf("failed to get list of releases: %w", err)
|
|
}
|
|
newVersion, err = latestMicroRelease(html, newVersion)
|
|
if err != nil {
|
|
return "", siteURL, err
|
|
}
|
|
}
|
|
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
|
|
if opt.Stable && opt.Beta {
|
|
return errors.New("--stable and --beta are mutually exclusive")
|
|
}
|
|
|
|
// The `cmount` tag is added by cmd/cmount/mount.go only if build is static.
|
|
_, tags := buildinfo.GetLinkingAndTags()
|
|
if strings.Contains(" "+tags+" ", " cmount ") && !cmount.ProvidedBy(runtime.GOOS) {
|
|
return errors.New("updating would discard the mount FUSE capability, aborting")
|
|
}
|
|
|
|
newVersion, siteURL, err := GetVersion(ctx, opt.Beta, opt.Version)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to detect new version: %w", err)
|
|
}
|
|
|
|
oldVersion := fs.Version
|
|
if newVersion == oldVersion {
|
|
fs.Logf(nil, "rclone is up to date")
|
|
return nil
|
|
}
|
|
|
|
// Install .deb/.rpm package if requested by user
|
|
if opt.Package == "deb" || opt.Package == "rpm" {
|
|
if opt.Check {
|
|
fmt.Println("Warning: --package flag is ignored in --check mode")
|
|
} else {
|
|
err := installPackage(ctx, opt.Beta, newVersion, siteURL, opt.Package)
|
|
if err == nil {
|
|
fs.Logf(nil, "Successfully updated rclone package from version %s to version %s", oldVersion, newVersion)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Get the current executable path
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to find executable: %w", err)
|
|
}
|
|
|
|
targetFile := opt.Output
|
|
if targetFile == "" {
|
|
targetFile = executable
|
|
}
|
|
|
|
if opt.Check {
|
|
fmt.Printf("Without --check this would install rclone version %s at %s\n", newVersion, targetFile)
|
|
return nil
|
|
}
|
|
|
|
// Make temporary file names and check for possible access errors in advance
|
|
var newFile string
|
|
if newFile, err = makeRandomExeName(targetFile, "new"); err != nil {
|
|
return err
|
|
}
|
|
savedFile := ""
|
|
if runtime.GOOS == "windows" {
|
|
savedFile = targetFile
|
|
savedFile = strings.TrimSuffix(savedFile, ".exe")
|
|
savedFile += ".old.exe"
|
|
}
|
|
|
|
if savedFile == executable || newFile == executable {
|
|
return fmt.Errorf("%s: a temporary file would overwrite the executable, specify a different --output path", targetFile)
|
|
}
|
|
|
|
if err := verifyAccess(targetFile); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Download the update as a temporary file
|
|
err = downloadUpdate(ctx, opt.Beta, newVersion, siteURL, newFile, "zip")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update rclone: %w", err)
|
|
}
|
|
|
|
err = replaceExecutable(targetFile, newFile, savedFile)
|
|
if err == nil {
|
|
fs.Logf(nil, "Successfully updated rclone from version %s to version %s", oldVersion, newVersion)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func installPackage(ctx context.Context, beta bool, version, siteURL, packageFormat string) error {
|
|
tempFile, err := os.CreateTemp("", "rclone.*."+packageFormat)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to write temporary package: %w", err)
|
|
}
|
|
packageFile := tempFile.Name()
|
|
_ = tempFile.Close()
|
|
defer func() {
|
|
if rmErr := os.Remove(packageFile); rmErr != nil {
|
|
fs.Errorf(nil, "%s: could not remove temporary package: %v", packageFile, rmErr)
|
|
}
|
|
}()
|
|
if err := downloadUpdate(ctx, beta, version, siteURL, packageFile, packageFormat); err != nil {
|
|
return err
|
|
}
|
|
|
|
packageCommand := "dpkg"
|
|
if packageFormat == "rpm" {
|
|
packageCommand = "rpm"
|
|
}
|
|
cmd := exec.Command(packageCommand, "-i", packageFile)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to run %s: %v", packageCommand, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func replaceExecutable(targetFile, newFile, savedFile string) error {
|
|
// Copy permission bits from the old executable
|
|
// (it was extracted with mode 0755)
|
|
fileInfo, err := os.Lstat(targetFile)
|
|
if err == nil {
|
|
if err = os.Chmod(newFile, fileInfo.Mode()); err != nil {
|
|
return fmt.Errorf("failed to set permission: %w", err)
|
|
}
|
|
}
|
|
|
|
if err = os.Remove(targetFile); os.IsNotExist(err) {
|
|
err = nil
|
|
}
|
|
|
|
if err != nil && savedFile != "" {
|
|
// Windows forbids removal of a running executable so we rename it.
|
|
// For starters, rename download as the original file with ".old.exe" appended.
|
|
var saveErr error
|
|
if saveErr = os.Remove(savedFile); os.IsNotExist(saveErr) {
|
|
saveErr = nil
|
|
}
|
|
if saveErr == nil {
|
|
saveErr = os.Rename(targetFile, savedFile)
|
|
}
|
|
if saveErr != nil {
|
|
// The ".old" file cannot be removed or cannot be renamed to.
|
|
// This usually means that the running executable has a name with ".old".
|
|
// This can happen in very rare cases, but we ought to handle it.
|
|
// Try inserting a randomness in the name to mitigate it.
|
|
fs.Debugf(nil, "%s: cannot replace old file, randomizing name", savedFile)
|
|
|
|
savedFile, saveErr = makeRandomExeName(targetFile, "old")
|
|
if saveErr == nil {
|
|
if saveErr = os.Remove(savedFile); os.IsNotExist(saveErr) {
|
|
saveErr = nil
|
|
}
|
|
}
|
|
if saveErr == nil {
|
|
saveErr = os.Rename(targetFile, savedFile)
|
|
}
|
|
}
|
|
if saveErr == nil {
|
|
fs.Infof(nil, "The old executable was saved as %s", savedFile)
|
|
err = nil
|
|
}
|
|
}
|
|
|
|
if err == nil {
|
|
err = os.Rename(newFile, targetFile)
|
|
}
|
|
if err != nil {
|
|
if rmErr := os.Remove(newFile); rmErr != nil {
|
|
fs.Errorf(nil, "%s: could not remove temporary file: %v", newFile, rmErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func makeRandomExeName(baseName, extension string) (string, error) {
|
|
const maxAttempts = 5
|
|
|
|
if runtime.GOOS == "windows" {
|
|
baseName = strings.TrimSuffix(baseName, ".exe")
|
|
extension += ".exe"
|
|
}
|
|
|
|
for range maxAttempts {
|
|
filename := fmt.Sprintf("%s.%s.%s", baseName, random.String(4), extension)
|
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
|
return filename, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("cannot find a file name like %s.xxxx.%s", baseName, extension)
|
|
}
|
|
|
|
func downloadUpdate(ctx context.Context, beta bool, version, siteURL, newFile, packageFormat string) error {
|
|
osName := runtime.GOOS
|
|
if osName == "darwin" {
|
|
osName = "osx"
|
|
}
|
|
arch := runtime.GOARCH
|
|
if arch == "arm" {
|
|
// Check the ARM compatibility level of the current CPU.
|
|
// We don't know if this matches the rclone binary currently running, it
|
|
// could for example be a ARMv6 variant running on a ARMv7 compatible CPU,
|
|
// so we will simply pick the best possible variant.
|
|
switch buildinfo.GetSupportedGOARM() {
|
|
case 7:
|
|
// This system can run any binaries built with GOARCH=arm, including GOARM=7.
|
|
// Pick the ARMv7 variant of rclone, published with suffix "arm-v7".
|
|
arch = "arm-v7"
|
|
case 6:
|
|
// This system can run binaries built with GOARCH=arm and GOARM=6 or lower.
|
|
// Pick the ARMv6 variant of rclone, published with suffix "arm-v6".
|
|
arch = "arm-v6"
|
|
case 5:
|
|
// This system can only run binaries built with GOARCH=arm and GOARM=5.
|
|
// Pick the ARMv5 variant of rclone, which also works without hardfloat,
|
|
// published with suffix "arm".
|
|
arch = "arm"
|
|
}
|
|
}
|
|
archiveFilename := fmt.Sprintf("rclone-%s-%s-%s.%s", version, osName, arch, packageFormat)
|
|
archiveURL := fmt.Sprintf("%s/%s/%s", siteURL, version, archiveFilename)
|
|
archiveBuf, err := downloadFile(ctx, archiveURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gotHash := sha256.Sum256(archiveBuf)
|
|
strHash := hex.EncodeToString(gotHash[:])
|
|
fs.Debugf(nil, "downloaded release archive with hashsum %s from %s", strHash, archiveURL)
|
|
|
|
// CI/CD does not provide hashsums for beta releases
|
|
if !beta {
|
|
if err := verifyHashsum(ctx, siteURL, version, archiveFilename, gotHash[:]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if packageFormat == "deb" || packageFormat == "rpm" {
|
|
if err := os.WriteFile(newFile, archiveBuf, 0644); err != nil {
|
|
return fmt.Errorf("cannot write temporary .%s: %w", packageFormat, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
entryName := fmt.Sprintf("rclone-%s-%s-%s/rclone", version, osName, arch)
|
|
if runtime.GOOS == "windows" {
|
|
entryName += ".exe"
|
|
}
|
|
|
|
// Extract executable to a temporary file, then replace it by an instant rename
|
|
err = extractZipToFile(archiveBuf, entryName, newFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fs.Debugf(nil, "extracted %s to %s", entryName, newFile)
|
|
return nil
|
|
}
|
|
|
|
func verifyAccess(file string) error {
|
|
admin := "root"
|
|
if runtime.GOOS == "windows" {
|
|
admin = "Administrator"
|
|
}
|
|
|
|
fileInfo, fileErr := os.Lstat(file)
|
|
|
|
if fileErr != nil {
|
|
dir := filepath.Dir(file)
|
|
dirInfo, dirErr := os.Lstat(dir)
|
|
if dirErr != nil {
|
|
return dirErr
|
|
}
|
|
if !dirInfo.Mode().IsDir() {
|
|
return fmt.Errorf("%s: parent path is not a directory, specify a different path using --output", dir)
|
|
}
|
|
if !writable(dir) {
|
|
return fmt.Errorf("%s: directory is not writable, please run self-update as %s", dir, admin)
|
|
}
|
|
}
|
|
|
|
if fileErr == nil && !fileInfo.Mode().IsRegular() {
|
|
return fmt.Errorf("%s: path is not a normal file, specify a different path using --output", file)
|
|
}
|
|
|
|
if fileErr == nil && !writable(file) {
|
|
return fmt.Errorf("%s: file is not writable, run self-update as %s", file, admin)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func findFileHash(buf []byte, filename string) (hash []byte, err error) {
|
|
lines := bufio.NewScanner(bytes.NewReader(buf))
|
|
for lines.Scan() {
|
|
tokens := strings.Split(lines.Text(), " ")
|
|
if len(tokens) == 2 && tokens[1] == filename {
|
|
if hash, err := hex.DecodeString(tokens[0]); err == nil {
|
|
return hash, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%s: unable to find hash", filename)
|
|
}
|
|
|
|
func extractZipToFile(buf []byte, entryName, newFile string) error {
|
|
zipReader, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var reader io.ReadCloser
|
|
for _, entry := range zipReader.File {
|
|
if entry.Name == entryName {
|
|
reader, err = entry.Open()
|
|
break
|
|
}
|
|
}
|
|
if reader == nil || err != nil {
|
|
return fmt.Errorf("%s: file not found in archive", entryName)
|
|
}
|
|
defer func() {
|
|
_ = reader.Close()
|
|
}()
|
|
|
|
err = os.Remove(newFile)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("%s: unable to create new file: %v", newFile, err)
|
|
}
|
|
writer, err := os.OpenFile(newFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.FileMode(0755))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(writer, reader)
|
|
_ = writer.Close()
|
|
if err != nil {
|
|
if rmErr := os.Remove(newFile); rmErr != nil {
|
|
fs.Errorf(nil, "%s: could not remove temporary file: %v", newFile, rmErr)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func downloadFile(ctx context.Context, url string) ([]byte, error) {
|
|
resp, err := fshttp.NewClient(ctx).Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer fs.CheckClose(resp.Body, &err)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed with %s downloading %s", resp.Status, url)
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|