mirror of
https://github.com/rclone/rclone.git
synced 2024-11-27 02:45:16 +01:00
5d6b8141ec
As of Go 1.16, the same functionality is now provided by package io or package os, and those implementations should be preferred in new code.
475 lines
14 KiB
Go
475 lines
14 KiB
Go
//go:build !noselfupdate
|
|
// +build !noselfupdate
|
|
|
|
// Package selfupdate provides the selfupdate command.
|
|
package selfupdate
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"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"
|
|
)
|
|
|
|
// 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.ReplaceAll(selfUpdateHelp, "|", "`"),
|
|
Run: func(command *cobra.Command, args []string) {
|
|
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()
|
|
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(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
|
|
arch := runtime.GOARCH
|
|
if osName == "darwin" {
|
|
osName = "osx"
|
|
}
|
|
|
|
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)
|
|
}
|