cmd: rclone selfupdate (#5080)
Implements self-update command Fixes #548 Fixes #5076
This commit is contained in:
parent
4d8ef7bca7
commit
6fa74340a0
11 changed files with 811 additions and 7 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
*~
|
||||
_junk/
|
||||
rclone
|
||||
rclone.exe
|
||||
build
|
||||
docs/public
|
||||
rclone.iml
|
||||
|
|
|
@ -47,6 +47,7 @@ import (
|
|||
_ "github.com/rclone/rclone/cmd/reveal"
|
||||
_ "github.com/rclone/rclone/cmd/rmdir"
|
||||
_ "github.com/rclone/rclone/cmd/rmdirs"
|
||||
_ "github.com/rclone/rclone/cmd/selfupdate"
|
||||
_ "github.com/rclone/rclone/cmd/serve"
|
||||
_ "github.com/rclone/rclone/cmd/settier"
|
||||
_ "github.com/rclone/rclone/cmd/sha1sum"
|
||||
|
|
|
@ -548,6 +548,9 @@ func Main() {
|
|||
setupRootCommand(Root)
|
||||
AddBackendFlags()
|
||||
if err := Root.Execute(); err != nil {
|
||||
if strings.HasPrefix(err.Error(), "unknown command") {
|
||||
Root.PrintErrf("You could use '%s selfupdate' to get latest features.\n\n", Root.CommandPath())
|
||||
}
|
||||
log.Fatalf("Fatal error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
22
cmd/selfupdate/help.go
Normal file
22
cmd/selfupdate/help.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package selfupdate
|
||||
|
||||
// Note: "|" will be replaced by backticks in the help string below
|
||||
var selfUpdateHelp string = `
|
||||
This command downloads the latest release of rclone and replaces
|
||||
the currently running binary. The download is verified with a hashsum
|
||||
and cryptographically signed signature.
|
||||
|
||||
The |--version VER| flag, if given, will update to a concrete version
|
||||
instead of the latest one. If you omit micro version from |VER| (for
|
||||
example |1.53|), the latest matching micro version will be used.
|
||||
|
||||
If you previously installed rclone via a package manager, the package may
|
||||
include local documentation or configure services. You may wish to update
|
||||
with the flag |--package deb| or |--package rpm| (whichever is correct for
|
||||
your OS) to update these too. This command with the default |--package zip|
|
||||
will update only the rclone executable so the local manual may become
|
||||
inaccurate after it.
|
||||
|
||||
Note: Windows forbids deletion of a currently running executable so this
|
||||
command will rename the old executable to 'rclone.old.exe' upon success.
|
||||
`
|
474
cmd/selfupdate/selfupdate.go
Normal file
474
cmd/selfupdate/selfupdate.go
Normal file
|
@ -0,0 +1,474 @@
|
|||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rclone/rclone/cmd"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/flags"
|
||||
"github.com/rclone/rclone/fs/fshttp"
|
||||
"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, errors.Wrap(err, "failed to get list of releases")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
newVersion, siteURL, err := GetVersion(ctx, opt.Beta, opt.Version)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to detect new version")
|
||||
}
|
||||
|
||||
if newVersion == "" {
|
||||
var err error
|
||||
_, newVersion, _, err = versionCmd.GetVersion(siteURL + "/version.txt")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to detect new version")
|
||||
}
|
||||
}
|
||||
|
||||
if newVersion == fs.Version {
|
||||
fmt.Println("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 {
|
||||
fmt.Printf("Successfully updated rclone package to version %s\n", newVersion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current executable path
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to find executable")
|
||||
}
|
||||
|
||||
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
|
||||
if strings.HasSuffix(savedFile, ".exe") {
|
||||
savedFile = savedFile[:len(savedFile)-4]
|
||||
}
|
||||
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 errors.Wrap(err, "failed to update rclone")
|
||||
}
|
||||
|
||||
err = replaceExecutable(targetFile, newFile, savedFile)
|
||||
if err == nil {
|
||||
fmt.Printf("Successfully updated rclone to version %s\n", newVersion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func installPackage(ctx context.Context, beta bool, version, siteURL, packageFormat string) error {
|
||||
tempFile, err := ioutil.TempFile("", "rclone.*."+packageFormat)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to write temporary package")
|
||||
}
|
||||
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 errors.Wrap(err, "failed to set permission")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf("The old executable was saved as %s\n", 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" {
|
||||
if strings.HasSuffix(baseName, ".exe") {
|
||||
baseName = baseName[:len(baseName)-4]
|
||||
}
|
||||
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 arch == "darwin" {
|
||||
arch = "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 := ioutil.WriteFile(newFile, archiveBuf, 0644); err != nil {
|
||||
return errors.Wrap(err, "cannot write temporary ."+packageFormat)
|
||||
}
|
||||
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 ioutil.ReadAll(resp.Body)
|
||||
}
|
198
cmd/selfupdate/selfupdate_test.go
Normal file
198
cmd/selfupdate/selfupdate_test.go
Normal file
|
@ -0,0 +1,198 @@
|
|||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fstest/testy"
|
||||
"github.com/rclone/rclone/lib/random"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
testy.SkipUnreliable(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// a beta version can only have "v" prepended
|
||||
resultVer, _, err := GetVersion(ctx, true, "1.2.3.4")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "v1.2.3.4", resultVer)
|
||||
|
||||
// but a stable version syntax should be checked
|
||||
_, _, err = GetVersion(ctx, false, "1")
|
||||
assert.Error(t, err)
|
||||
_, _, err = GetVersion(ctx, false, "1.")
|
||||
assert.Error(t, err)
|
||||
_, _, err = GetVersion(ctx, false, "1.2.")
|
||||
assert.Error(t, err)
|
||||
_, _, err = GetVersion(ctx, false, "1.2.3.4")
|
||||
assert.Error(t, err)
|
||||
|
||||
// incomplete stable version should have micro release added
|
||||
resultVer, _, err = GetVersion(ctx, false, "1.52")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "v1.52.3", resultVer)
|
||||
}
|
||||
|
||||
func makeTestDir() (testDir string, err error) {
|
||||
const maxAttempts = 5
|
||||
testDirBase := filepath.Join(os.TempDir(), "rclone-test-selfupdate.")
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
testDir = testDirBase + random.String(4)
|
||||
err = os.MkdirAll(testDir, os.ModePerm)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestInstallOnLinux(t *testing.T) {
|
||||
testy.SkipUnreliable(t)
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("this is a Linux only test")
|
||||
}
|
||||
|
||||
// Prepare for test
|
||||
ctx := context.Background()
|
||||
testDir, err := makeTestDir()
|
||||
assert.NoError(t, err)
|
||||
path := filepath.Join(testDir, "rclone")
|
||||
defer func() {
|
||||
_ = os.Chmod(path, 0644)
|
||||
_ = os.RemoveAll(testDir)
|
||||
}()
|
||||
|
||||
regexVer := regexp.MustCompile(`v[0-9]\S+`)
|
||||
|
||||
betaVer, _, err := GetVersion(ctx, true, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Must do nothing if version isn't changing
|
||||
assert.NoError(t, InstallUpdate(ctx, &Options{Beta: true, Output: path, Version: fs.Version}))
|
||||
|
||||
// Must fail on non-writable file
|
||||
assert.NoError(t, ioutil.WriteFile(path, []byte("test"), 0644))
|
||||
assert.NoError(t, os.Chmod(path, 0000))
|
||||
err = (InstallUpdate(ctx, &Options{Beta: true, Output: path}))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "run self-update as root")
|
||||
|
||||
// Must keep non-standard permissions
|
||||
assert.NoError(t, os.Chmod(path, 0644))
|
||||
assert.NoError(t, InstallUpdate(ctx, &Options{Beta: true, Output: path}))
|
||||
|
||||
info, err := os.Stat(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0644), info.Mode().Perm())
|
||||
|
||||
// Must remove temporary files
|
||||
files, err := ioutil.ReadDir(testDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(files))
|
||||
|
||||
// Must contain valid executable
|
||||
assert.NoError(t, os.Chmod(path, 0755))
|
||||
cmd := exec.Command(path, "version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cmd.ProcessState.Success())
|
||||
assert.Equal(t, betaVer, regexVer.FindString(string(output)))
|
||||
}
|
||||
|
||||
func TestRenameOnWindows(t *testing.T) {
|
||||
testy.SkipUnreliable(t)
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("this is a Windows only test")
|
||||
}
|
||||
|
||||
// Prepare for test
|
||||
ctx := context.Background()
|
||||
|
||||
testDir, err := makeTestDir()
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(testDir)
|
||||
}()
|
||||
|
||||
path := filepath.Join(testDir, "rclone.exe")
|
||||
regexVer := regexp.MustCompile(`v[0-9]\S+`)
|
||||
|
||||
stableVer, _, err := GetVersion(ctx, false, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
betaVer, _, err := GetVersion(ctx, true, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Must not create temporary files when target doesn't exist
|
||||
assert.NoError(t, InstallUpdate(ctx, &Options{Beta: true, Output: path}))
|
||||
|
||||
files, err := ioutil.ReadDir(testDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(files))
|
||||
|
||||
// Must save running executable as the "old" file
|
||||
cmdWait := exec.Command(path, "config")
|
||||
stdinWait, err := cmdWait.StdinPipe() // Make it run waiting for input
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, cmdWait.Start())
|
||||
|
||||
assert.NoError(t, InstallUpdate(ctx, &Options{Beta: false, Output: path}))
|
||||
files, err = ioutil.ReadDir(testDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(files))
|
||||
|
||||
pathOld := filepath.Join(testDir, "rclone.old.exe")
|
||||
_, err = os.Stat(pathOld)
|
||||
assert.NoError(t, err)
|
||||
|
||||
cmd := exec.Command(path, "version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cmd.ProcessState.Success())
|
||||
assert.Equal(t, stableVer, regexVer.FindString(string(output)))
|
||||
|
||||
cmdOld := exec.Command(pathOld, "version")
|
||||
output, err = cmdOld.CombinedOutput()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cmdOld.ProcessState.Success())
|
||||
assert.Equal(t, betaVer, regexVer.FindString(string(output)))
|
||||
|
||||
// Stop previous waiting executable, run new and saved executables
|
||||
_ = stdinWait.Close()
|
||||
_ = cmdWait.Wait()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
cmdWait = exec.Command(path, "config")
|
||||
stdinWait, err = cmdWait.StdinPipe()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, cmdWait.Start())
|
||||
|
||||
cmdWaitOld := exec.Command(pathOld, "config")
|
||||
stdinWaitOld, err := cmdWaitOld.StdinPipe()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, cmdWaitOld.Start())
|
||||
|
||||
// Updating when the "old" executable is running must produce a random "old" file
|
||||
assert.NoError(t, InstallUpdate(ctx, &Options{Beta: true, Output: path}))
|
||||
files, err = ioutil.ReadDir(testDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(files))
|
||||
|
||||
// Stop all waiting executables
|
||||
_ = stdinWait.Close()
|
||||
_ = cmdWait.Wait()
|
||||
_ = stdinWaitOld.Close()
|
||||
_ = cmdWaitOld.Wait()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
72
cmd/selfupdate/verify.go
Normal file
72
cmd/selfupdate/verify.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package selfupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/clearsign"
|
||||
)
|
||||
|
||||
var ncwPublicKeyPGP = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGiBDuy3V0RBADVQOAF5aFiCxD3t2h6iAF2WMiaMlgZ6kX2i/u7addNkzX71VU9
|
||||
7NpI0SnsP5YWt+gEedST6OmFbtLfZWCR4KWn5XnNdjCMNhxaH6WccVqNm4ALPIqT
|
||||
59uVjkgf8RISmmoNJ1d+2wMWjQTUfwOEmoIgH6n+2MYNUKuctBrwAACflwCg1I1Q
|
||||
O/prv/5hczdpQCs+fL87DxsD/Rt7pIXvsIOZyQWbIhSvNpGalJuMkW5Jx92UjsE9
|
||||
1Ipo3Xr6SGRPgW9+NxAZAsiZfCX/19knAyNrN9blwL0rcPDnkhdGwK69kfjF+wq+
|
||||
QbogRGodbKhqY4v+cMNkKiemBuTQiWPkpKjifwNsD1fNjNKfDP3pJ64Yz7a4fuzV
|
||||
X1YwBACpKVuEen34lmcX6ziY4jq8rKibKBs4JjQCRO24kYoHDULVe+RS9krQWY5b
|
||||
e0foDhru4dsKccefK099G+WEzKVCKxupstWkTT/iJwajR8mIqd4AhD0wO9W3MCfV
|
||||
Ov8ykMDZ7qBWk1DHc87Ep3W1o8t8wq74ifV+HjhhWg8QAylXg7QlTmljayBDcmFp
|
||||
Zy1Xb29kIDxuaWNrQGNyYWlnLXdvb2QuY29tPohxBBMRCAAxBQsHCgMEAxUDAgMW
|
||||
AgECF4AWIQT79zfs6firGGBL0qyTk14C/ztU+gUCXjg2UgIZAQAKCRCTk14C/ztU
|
||||
+lmmAJ4jH5FyULzStjisuTvHLTVz6G44eQCfaR5QGZFPseenE5ic2WeQcBcmtoG5
|
||||
Ag0EO7LdgRAIAI6QdFBg3/xa1gFKPYy1ihV9eSdGqwWZGJvokWsfCvHy5180tj/v
|
||||
UNOLAJrdqglMSvevNTXe8bT65D6423AAsLhch9wq/aNqrHolTYABzxRigjcS1//T
|
||||
yln5naGUzlVQXDVfrDk3Md/NrkdOFj7r/YyMF0+iWwpFz2qAjL95i5wfVZ1kWGrT
|
||||
2AmivE1wD1sWT/Ja3FDI0NRkU0Nbz/a0TKe4ml8iLVtZXpTRbxxCCPdkHXXgSyu1
|
||||
eZ4NrF/wTJuvwGn12TJ1EF95aVkHxAUw0+KmLGdcyBG+IKuHamrsjWIAXGXV///K
|
||||
AxPgUthccQ03HMjltFsrdmen5Q034YM3eOsAAwUH/jAKiIAA8LpZmZPnt9GZ4+Ol
|
||||
Zp22VAfyfDOFl4Ol+cWjkLAgjAFsm5gnOKcRSE/9XPxnQqkhw7+ZygYuUMgTDJ99
|
||||
/5IM1UQL3ooS+oFrDaE99S8bLeOe17skcdXcA/K83VqD9m93rQRnbtD+75zqKkZn
|
||||
9WNFyKCXg5P6PFPdNYRtlQKOcwFR9mHRLUmapQSAM8Y2pCgALZ7GViKQca8/TT1T
|
||||
gZk9fJMZYGez+IlOPxTJxjn80+vywk4/wdIWSiQj+8u5RzT9sjmm77wbMVNGRqYd
|
||||
W/EemW9Zz9vi0CIvJGgbPMqcuxw8e/5lnuQ6Mi3uDR0P2RNIAhFrdZpVSME8xQaI
|
||||
RgQYEQIABgUCO7LdgQAKCRCTk14C/ztU+mLBAKC2cdFy7eLaQAvyzcE2VK6HVIjn
|
||||
JACguA00bxLQuJ4+RCJrLFZP8ZlN2sc=
|
||||
=TtR5
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
func verifyHashsum(ctx context.Context, siteURL, version, archive string, hash []byte) error {
|
||||
sumsURL := fmt.Sprintf("%s/%s/SHA256SUMS", siteURL, version)
|
||||
sumsBuf, err := downloadFile(ctx, sumsURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fs.Debugf(nil, "downloaded hashsum list: %s", sumsURL)
|
||||
|
||||
keyRing, err := openpgp.ReadArmoredKeyRing(strings.NewReader(ncwPublicKeyPGP))
|
||||
if err != nil {
|
||||
return errors.New("unsupported signing key")
|
||||
}
|
||||
block, rest := clearsign.Decode(sumsBuf)
|
||||
// block.Bytes = block.Bytes[1:] // uncomment to test invalid signature
|
||||
_, err = openpgp.CheckDetachedSignature(keyRing, bytes.NewReader(block.Bytes), block.ArmoredSignature.Body)
|
||||
if err != nil || len(rest) > 0 {
|
||||
return errors.New("invalid hashsum signature")
|
||||
}
|
||||
|
||||
wantHash, err := findFileHash(sumsBuf, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(hash, wantHash) {
|
||||
return fmt.Errorf("archive hash mismatch: want %02x vs got %02x", wantHash, hash)
|
||||
}
|
||||
return nil
|
||||
}
|
11
cmd/selfupdate/writable_unix.go
Normal file
11
cmd/selfupdate/writable_unix.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
// +build !windows,!plan9,!js
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func writable(path string) bool {
|
||||
return unix.Access(path, unix.W_OK) == nil
|
||||
}
|
7
cmd/selfupdate/writable_unsupported.go
Normal file
7
cmd/selfupdate/writable_unsupported.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
// +build plan9 js
|
||||
|
||||
package selfupdate
|
||||
|
||||
func writable(path string) bool {
|
||||
return true
|
||||
}
|
16
cmd/selfupdate/writable_windows.go
Normal file
16
cmd/selfupdate/writable_windows.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
// +build windows
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func writable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
const UserWritableBit = 128
|
||||
if err == nil {
|
||||
return info.Mode().Perm()&UserWritableBit != 0
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -59,7 +59,7 @@ Or
|
|||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd.CheckArgs(0, 0, command, args)
|
||||
if check {
|
||||
checkVersion()
|
||||
CheckVersion()
|
||||
} else {
|
||||
cmd.ShowVersion()
|
||||
}
|
||||
|
@ -74,8 +74,8 @@ func stripV(s string) string {
|
|||
return s
|
||||
}
|
||||
|
||||
// getVersion gets the version by checking the download repository passed in
|
||||
func getVersion(url string) (v *semver.Version, vs string, date time.Time, err error) {
|
||||
// GetVersion gets the version available for download
|
||||
func GetVersion(url string) (v *semver.Version, vs string, date time.Time, err error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return v, vs, date, err
|
||||
|
@ -101,9 +101,8 @@ func getVersion(url string) (v *semver.Version, vs string, date time.Time, err e
|
|||
return v, vs, date, err
|
||||
}
|
||||
|
||||
// check the current version against available versions
|
||||
func checkVersion() {
|
||||
// Get Current version
|
||||
// CheckVersion checks the installed version against available downloads
|
||||
func CheckVersion() {
|
||||
vCurrent, err := semver.NewVersion(stripV(fs.Version))
|
||||
if err != nil {
|
||||
fs.Errorf(nil, "Failed to parse version: %v", err)
|
||||
|
@ -111,7 +110,7 @@ func checkVersion() {
|
|||
const timeFormat = "2006-01-02"
|
||||
|
||||
printVersion := func(what, url string) {
|
||||
v, vs, t, err := getVersion(url + "version.txt")
|
||||
v, vs, t, err := GetVersion(url + "version.txt")
|
||||
if err != nil {
|
||||
fs.Errorf(nil, "Failed to get rclone %s version: %v", what, err)
|
||||
return
|
||||
|
|
Loading…
Reference in a new issue