123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- //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)
- }
|