252 lines
6.9 KiB
Go
252 lines
6.9 KiB
Go
package update
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/mathismqn/godeez/internal/buildinfo"
|
|
"github.com/mathismqn/godeez/internal/fsutil"
|
|
)
|
|
|
|
// managedPrefixes are install roots owned by a package manager. Overwriting a
|
|
// binary there would leave the package manager's database describing a file
|
|
// that no longer matches, and its next upgrade would silently revert the
|
|
// self-update. Users on these installs are pointed back at their package
|
|
// manager instead.
|
|
var managedPrefixes = []string{
|
|
"/nix/store",
|
|
"/opt/homebrew",
|
|
"/usr/local/Cellar",
|
|
"/home/linuxbrew",
|
|
"/snap",
|
|
"/var/lib/flatpak",
|
|
}
|
|
|
|
// resolveTarget returns the binary that should be replaced, or an error
|
|
// explaining why self-updating is not appropriate here.
|
|
//
|
|
// Symlinks are resolved first so the real file is replaced rather than the
|
|
// link: package managers commonly expose a binary through a symlink, and
|
|
// following it is what makes the managed prefix check meaningful. A build
|
|
// that was not produced by a release is refused outright, since there is no
|
|
// version to compare against.
|
|
func resolveTarget() (string, error) {
|
|
if buildinfo.IsDev() {
|
|
return "", fmt.Errorf("development build cannot self-update; install a release from https://github.com/%s/%s/releases",
|
|
repoOwner, repoName)
|
|
}
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to locate the running binary: %w", err)
|
|
}
|
|
|
|
target := exe
|
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
|
target = resolved
|
|
}
|
|
|
|
for _, prefix := range managedPrefixes {
|
|
if target == prefix || strings.HasPrefix(target, prefix+"/") {
|
|
return "", fmt.Errorf("%s was installed by a package manager; update it with that instead", target)
|
|
}
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
func CheckUpdatable() error {
|
|
_, err := resolveTarget()
|
|
|
|
return err
|
|
}
|
|
|
|
// checkWritable proves the install directory is writable by actually creating
|
|
// and removing a file there. Inspecting permission bits would not account for
|
|
// read-only mounts or the platform's own rules, and finding out only after
|
|
// the download has finished wastes the user's time.
|
|
func checkWritable(dir string) error {
|
|
f, err := os.CreateTemp(dir, tmpPattern)
|
|
if err != nil {
|
|
hint := "re-run with sudo"
|
|
if runtime.GOOS == "windows" {
|
|
hint = "re-run from an elevated prompt"
|
|
}
|
|
|
|
return fmt.Errorf("cannot write to %s: %w; %s", dir, err, hint)
|
|
}
|
|
|
|
name := f.Name()
|
|
f.Close()
|
|
os.Remove(name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Apply downloads release and replaces the running binary with it.
|
|
//
|
|
// The order of these steps is the safety property. The expected checksum is
|
|
// fetched before the asset, so a release that does not publish one fails
|
|
// before anything is downloaded. The download lands in a temporary file in
|
|
// the install directory, which keeps the final rename on the same filesystem
|
|
// and therefore atomic. The binary is only replaced after the checksum
|
|
// matches, so a corrupted or tampered download can never be executed.
|
|
func (u *Updater) Apply(ctx context.Context, release *Release) error {
|
|
target, err := resolveTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dir := filepath.Dir(target)
|
|
if err := checkWritable(dir); err != nil {
|
|
return err
|
|
}
|
|
|
|
asset, err := release.assetForRuntime()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
want, err := u.fetchChecksum(ctx, release, asset.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
u.step("Downloading %s", asset.Name)
|
|
tmp, sum, err := u.download(ctx, dir, asset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer fsutil.Remove(tmp)
|
|
|
|
u.step("Verifying checksum")
|
|
if sum != want {
|
|
return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset.Name, want, sum)
|
|
}
|
|
|
|
if err := os.Chmod(tmp, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
u.step("Replacing %s", target)
|
|
|
|
return u.replaceBinary(target, tmp)
|
|
}
|
|
|
|
func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName string) (string, error) {
|
|
asset, ok := release.asset(checksumsAsset)
|
|
if !ok {
|
|
return "", fmt.Errorf("release %s does not publish %s", release.TagName, checksumsAsset)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
|
|
defer cancel()
|
|
|
|
body, err := u.get(ctx, asset.URL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer body.Close()
|
|
|
|
return parseChecksums(io.LimitReader(body, maxResponseSize), assetName)
|
|
}
|
|
|
|
// parseChecksums finds the digest for name in a sha256sum style file.
|
|
//
|
|
// The optional "*" before the filename is the marker sha256sum uses for
|
|
// binary mode and is not part of the name.
|
|
func parseChecksums(r io.Reader, name string) (string, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
for scanner.Scan() {
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) != 2 {
|
|
continue
|
|
}
|
|
if strings.TrimPrefix(fields[1], "*") == name {
|
|
return strings.ToLower(fields[0]), nil
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return "", fmt.Errorf("no checksum listed for %s", name)
|
|
}
|
|
|
|
// download writes asset to a temporary file in dir and returns its path and
|
|
// sha256. The hash is computed while streaming, so the file is never read a
|
|
// second time and never has to be held in memory.
|
|
func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
|
|
defer cancel()
|
|
|
|
body, err := u.get(ctx, asset.URL, nil)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer body.Close()
|
|
|
|
f, err := os.CreateTemp(dir, tmpPattern)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
tmp := f.Name()
|
|
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(io.MultiWriter(f, hash), body); err != nil {
|
|
f.Close()
|
|
fsutil.Remove(tmp)
|
|
|
|
return "", "", fmt.Errorf("failed to download %s: %w", asset.Name, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
fsutil.Remove(tmp)
|
|
|
|
return "", "", err
|
|
}
|
|
|
|
return tmp, hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
// replaceBinary swaps the new binary into place.
|
|
//
|
|
// Unix lets a running executable be renamed over, so a single atomic rename
|
|
// is enough. Windows locks the file of a running process, so the current
|
|
// binary has to be moved aside first, which leaves a window where the target
|
|
// does not exist; if installing the replacement then fails, the old one is
|
|
// moved back. The .old file is removed on the next update rather than
|
|
// immediately, since it is still locked while this process runs.
|
|
func (u *Updater) replaceBinary(target, tmp string) error {
|
|
if runtime.GOOS != "windows" {
|
|
return os.Rename(tmp, target)
|
|
}
|
|
|
|
old := target + ".old"
|
|
os.Remove(old)
|
|
|
|
if err := os.Rename(target, old); err != nil {
|
|
return fmt.Errorf("failed to move the current binary aside: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmp, target); err != nil {
|
|
if rollbackErr := os.Rename(old, target); rollbackErr != nil {
|
|
return fmt.Errorf("failed to install the new binary: %w; the previous one could not be restored from %s: %v",
|
|
err, old, rollbackErr)
|
|
}
|
|
|
|
return fmt.Errorf("failed to install the new binary: %w", err)
|
|
}
|
|
|
|
os.Remove(old)
|
|
|
|
return nil
|
|
}
|