501 lines
15 KiB
Go
501 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"
|
|
"log"
|
|
"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: 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" {
|
|
log.Fatalf("--package should be one of zip|deb|rpm")
|
|
}
|
|
if runtime.GOOS != "linux" {
|
|
log.Fatalf(".deb and .rpm packages are supported only on Linux")
|
|
} else if os.Geteuid() != 0 && !Opt.Check {
|
|
log.Fatalf(".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 {
|
|
log.Fatalf("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)
|
|
}
|
|
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")
|
|
}
|
|
// Use the fact that releases in the index are sorted by date
|
|
lastSubver := allSubvers[len(allSubvers)-1]
|
|
newVersion = lastSubver[8 : len(lastSubver)-2]
|
|
}
|
|
return
|
|
}
|
|
|
|
// 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 attempt := 0; attempt < maxAttempts; attempt++ {
|
|
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)
|
|
}
|