package resticinstaller import ( "errors" "fmt" "os" "os/exec" "path" "path/filepath" "regexp" "runtime" "strings" "sync" "github.com/garethgeorge/backrest/internal/env" "github.com/gofrs/flock" "go.uber.org/zap" ) var ( ErrResticNotFound = errors.New("no restic binary") ) var ( RequiredResticVersion = "0.18.0" tryFindRestic sync.Once findResticErr error foundResticPath string ) func getResticVersion(binary string) (string, error) { cmd := exec.Command(binary, "version") out, err := cmd.Output() if err != nil { return "", fmt.Errorf("exec %v: %w", cmd.String(), err) } match := regexp.MustCompile(`restic\s+((\d+\.\d+\.\d+))`).FindSubmatch(out) if len(match) < 2 { return "", fmt.Errorf("could not find restic version in output: %s", out) } return string(match[1]), nil } func assertResticVersion(binary string) error { if version, err := getResticVersion(binary); err != nil { return fmt.Errorf("determine restic version: %w", err) } else if version != RequiredResticVersion { return fmt.Errorf("want restic %v but found version %v", RequiredResticVersion, version) } return nil } func resticDownloadURL(version string) string { if runtime.GOOS == "windows" { // restic is only built for 386 and amd64 on Windows, default to amd64 for other platforms (e.g. arm64.) arch := "amd64" if runtime.GOARCH == "386" || runtime.GOARCH == "amd64" { arch = runtime.GOARCH } return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_windows_%v.zip", version, version, arch) } return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_%v_%v.bz2", version, version, runtime.GOOS, runtime.GOARCH) } func hashDownloadURL(version string) string { return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/SHA256SUMS", version) } func sigDownloadURL(version string) string { return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/SHA256SUMS.asc", version) } func verify(sha256 string) error { sha256sums, err := getURL(hashDownloadURL(RequiredResticVersion)) if err != nil { return fmt.Errorf("get sha256sums: %w", err) } signature, err := getURL(sigDownloadURL(RequiredResticVersion)) if err != nil { return fmt.Errorf("get signature: %w", err) } if ok, err := gpgVerify(sha256sums, signature); !ok || err != nil { return fmt.Errorf("gpg verification failed: ok=%v err=%v", ok, err) } if !strings.Contains(string(sha256sums), sha256) { fmt.Fprintf(os.Stderr, "sha256sums:\n%v\n", string(sha256sums)) return fmt.Errorf("sha256sums do not contain %v", sha256) } return nil } func installResticIfNotExists(resticInstallPath string) error { if _, err := os.Stat(resticInstallPath); err == nil { // file is now installed, probably by another process. We can return. return nil } if err := os.MkdirAll(path.Dir(resticInstallPath), 0755); err != nil { return fmt.Errorf("create restic install directory %v: %w", path.Dir(resticInstallPath), err) } hash, err := downloadFile(resticDownloadURL(RequiredResticVersion), resticInstallPath+".tmp") if err != nil { return err } if err := verify(hash); err != nil { os.Remove(resticInstallPath) // try to remove the bad binary. return fmt.Errorf("failed to verify the authenticity of the downloaded restic binary: %v", err) } if err := os.Chmod(resticInstallPath+".tmp", 0755); err != nil { return fmt.Errorf("chmod executable %v: %w", resticInstallPath, err) } if err := os.Rename(resticInstallPath+".tmp", resticInstallPath); err != nil { return fmt.Errorf("rename %v.tmp to %v: %w", resticInstallPath, resticInstallPath, err) } return nil } func removeOldVersions(installDir string) { files, err := os.ReadDir(installDir) if err != nil { zap.S().Errorf("remove old restic versions: read dir %v: %v", installDir, err) return } for _, file := range files { if !strings.HasPrefix(file.Name(), "restic-") || strings.Contains(file.Name(), RequiredResticVersion) { continue } if err := os.Remove(path.Join(installDir, file.Name())); err != nil { zap.S().Errorf("remove old restic version %v: %v", file.Name(), err) } } } func installResticHelper(resticInstallPath string) { if _, err := os.Stat(resticInstallPath); err == nil { zap.S().Infof("replacing restic binary in data dir due to failed check: %v", err) if err := os.Remove(resticInstallPath); err != nil { zap.S().Errorf("failed to remove old restic binary %v: %v", resticInstallPath, err) } } zap.S().Infof("downloading restic %v to %v...", RequiredResticVersion, resticInstallPath) if err := installResticIfNotExists(resticInstallPath); err != nil { zap.S().Errorf("failed to install restic %v: %v", RequiredResticVersion, err) return } zap.S().Infof("installed restic %v", RequiredResticVersion) // TODO: this check is no longer needed, remove it after a few releases. removeOldVersions(path.Dir(resticInstallPath)) } func tryFindOrInstall() (string, error) { // Check if restic is provided. resticBinOverride := env.ResticBinPath() if resticBinOverride != "" { if err := assertResticVersion(resticBinOverride); err != nil { zap.S().Warnf("restic binary %q may not be supported by backrest: %v", resticBinOverride, err) } if _, err := os.Stat(resticBinOverride); err != nil { if !errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("check if restic binary exists at %v: %v", resticBinOverride, err) } return "", fmt.Errorf("no restic binary found at %v", resticBinOverride) } return resticBinOverride, nil } // Search the PATH for the specific restic version. if binPath, err := exec.LookPath("restic"); err == nil { if err := assertResticVersion(binPath); err == nil { zap.S().Infof("restic binary %q in $PATH matches required version %v, it will be used for backrest commands", binPath, RequiredResticVersion) return binPath, nil } else { zap.S().Infof("restic binary %q in $PATH is not being used, it may not be supported by backrest: %v", binPath, err) } } // Check for restic installation in data directory. var resticInstallPath string if runtime.GOOS == "windows" { // on windows use a path relative to the executable. resticInstallPath, _ = filepath.Abs(path.Join(path.Dir(os.Args[0]), "restic.exe")) } else { resticInstallPath = filepath.Join(env.DataDir(), "restic") } if err := os.MkdirAll(filepath.Dir(resticInstallPath), 0700); err != nil { return "", fmt.Errorf("create restic install directory %v: %w", path.Dir(resticInstallPath), err) } // Install restic if not found OR if the version is not the required version if err := assertResticVersion(resticInstallPath); err != nil { lock := flock.New(filepath.Join(filepath.Dir(resticInstallPath), "install.lock")) if err := lock.Lock(); err != nil { return "", fmt.Errorf("acquire lock on restic install dir %v: %v", lock.Path(), err) } defer lock.Unlock() // Check again after acquiring the lock. if err := assertResticVersion(resticInstallPath); err != nil { zap.S().Errorf("could not verify version of binary %v: %v", resticInstallPath, err) installResticHelper(resticInstallPath) } } zap.S().Infof("restic binary %v in data dir will be used as no system install matching required version %v is found", resticInstallPath, RequiredResticVersion) return resticInstallPath, nil } // FindOrInstallResticBinary first tries to find the restic binary if provided as an environment variable. Otherwise it downloads restic if not already installed. func FindOrInstallResticBinary() (string, error) { tryFindRestic.Do(func() { foundResticPath, findResticErr = tryFindOrInstall() }) if findResticErr != nil { return "", findResticErr } if foundResticPath == "" { return "", ErrResticNotFound } return foundResticPath, nil }