//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, "|", "`"), Annotations: map[string]string{ "versionIntroduced": "v1.55", }, 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 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) }