diff --git a/.gitignore b/.gitignore index 9a877432c..b20174667 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *~ _junk/ rclone +rclone.exe build docs/public rclone.iml diff --git a/cmd/all/all.go b/cmd/all/all.go index 3c899fa56..3d3f09b3f 100644 --- a/cmd/all/all.go +++ b/cmd/all/all.go @@ -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" diff --git a/cmd/cmd.go b/cmd/cmd.go index a5b754fe2..3192086d6 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -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) } } diff --git a/cmd/selfupdate/help.go b/cmd/selfupdate/help.go new file mode 100644 index 000000000..0ec55cbf7 --- /dev/null +++ b/cmd/selfupdate/help.go @@ -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. +` diff --git a/cmd/selfupdate/selfupdate.go b/cmd/selfupdate/selfupdate.go new file mode 100644 index 000000000..5ecadad4c --- /dev/null +++ b/cmd/selfupdate/selfupdate.go @@ -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) +} diff --git a/cmd/selfupdate/selfupdate_test.go b/cmd/selfupdate/selfupdate_test.go new file mode 100644 index 000000000..567643716 --- /dev/null +++ b/cmd/selfupdate/selfupdate_test.go @@ -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) +} diff --git a/cmd/selfupdate/verify.go b/cmd/selfupdate/verify.go new file mode 100644 index 000000000..115119fed --- /dev/null +++ b/cmd/selfupdate/verify.go @@ -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 +} diff --git a/cmd/selfupdate/writable_unix.go b/cmd/selfupdate/writable_unix.go new file mode 100644 index 000000000..f7c8a59fb --- /dev/null +++ b/cmd/selfupdate/writable_unix.go @@ -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 +} diff --git a/cmd/selfupdate/writable_unsupported.go b/cmd/selfupdate/writable_unsupported.go new file mode 100644 index 000000000..40f1d2429 --- /dev/null +++ b/cmd/selfupdate/writable_unsupported.go @@ -0,0 +1,7 @@ +// +build plan9 js + +package selfupdate + +func writable(path string) bool { + return true +} diff --git a/cmd/selfupdate/writable_windows.go b/cmd/selfupdate/writable_windows.go new file mode 100644 index 000000000..8270d3936 --- /dev/null +++ b/cmd/selfupdate/writable_windows.go @@ -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 +} diff --git a/cmd/version/version.go b/cmd/version/version.go index 04f157e7f..85a049b37 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -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