Files
upd/install.go
T

373 lines
9.6 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
)
// target is one file to write, plus the name to look for inside the asset.
type target struct {
name string // inside the archive
out string // file name on disk
dest string // full path
}
func resolveTargets(spec *Spec, names []name, goos string) ([]target, error) {
want := spec.Install
if want == "" {
want = os.Getenv("UPD_INSTALL")
}
want = expandTilde(want)
ext := ""
if goos == "windows" {
ext = ".exe"
}
// --install is a directory unless it clearly points at a single binary:
// an existing file, or a last segment that is one of the binary names.
// Everything else is a directory (and gets created) - otherwise a config
// line like "install=~/bin" would create a *file* called bin.
var dir, single string
if want != "" {
leaf := filepath.Base(want)
isBin := false
for _, n := range names {
if leaf == n.out || leaf == n.out+ext {
isBin = true
}
}
st, err := os.Stat(want)
isDir := err == nil && st.IsDir()
isFile := err == nil && st.Mode().IsRegular()
if !isDir && (isBin || isFile) {
if len(names) > 1 {
return nil, fmt.Errorf("--install points at the file %q but %d binaries were requested; give a directory instead", want, len(names))
}
single = want
dir = filepath.Dir(want)
} else {
dir = strings.TrimRight(want, string(os.PathSeparator))
if dir == "" {
dir = want
}
}
} else {
dir = defaultDir(names[0].out)
}
if err := makeDir(dir); err != nil {
return nil, err
}
var targets []target
for _, n := range names {
dest := single
if dest == "" {
dest = filepath.Join(dir, n.out+ext)
}
// Replacing a symlink would silently break the link, so follow it.
if st, err := os.Lstat(dest); err == nil && st.Mode()&os.ModeSymlink != 0 {
if real, err := filepath.EvalSymlinks(dest); err == nil {
info("Note: %s is a symlink, installing to %s", dest, real)
dest = real
}
}
targets = append(targets, target{name: n.src, out: n.out, dest: dest})
}
return targets, nil
}
// Without --install: replace the binary already on PATH, else ~/.local/bin.
func defaultDir(bin string) string {
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir == "" {
continue
}
p := filepath.Join(dir, bin)
if st, err := os.Stat(p); err == nil && st.Mode()&0o111 != 0 && writable(dir) {
return dir
}
}
return filepath.Join(homeDir(), ".local", "bin")
}
// Actually try it: permission bits say nothing about a read-only mount, and
// this runs right before we would write there anyway.
func writable(dir string) bool {
f, err := os.CreateTemp(dir, ".upd-*")
if err != nil {
return false
}
f.Close()
os.Remove(f.Name())
return true
}
func makeDir(dir string) error {
if opt.dryRun || opt.list || opt.check {
return nil
}
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("cannot create target directory %s: %w", dir, err)
}
return nil
}
func inPath(dir string) bool {
return slices.Contains(filepath.SplitList(os.Getenv("PATH")), dir)
}
// ==============================================================================
// State
// ==============================================================================
func stateDir() string {
base := os.Getenv("XDG_STATE_HOME")
if base == "" {
base = filepath.Join(homeDir(), ".local", "state")
}
return filepath.Join(base, "upd")
}
var unsafeChars = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// One state file per installed path; readable name plus a hash for uniqueness.
// The layout matches the Perl version so both can share a state directory.
func stateFile(dest string) string {
key := unsafeChars.ReplaceAllString(dest, "_")
key = strings.Trim(key, "_")
if len(key) > 70 {
key = key[len(key)-70:]
}
sum := sha256.Sum256([]byte(dest))
return filepath.Join(stateDir(), key+"."+hex.EncodeToString(sum[:])[:8]+".json")
}
// state is written with the keys in the order Perl's canonical JSON produces,
// so a file written by either version reads the same in a diff.
type state struct {
Asset string `json:"asset"`
AssetSize int64 `json:"asset_size"`
Binary string `json:"binary"`
BinarySHA256 string `json:"binary_sha256"`
Dest string `json:"dest"`
Forge string `json:"forge"`
InstalledAt string `json:"installed_at"`
Repo string `json:"repo"`
Stamp string `json:"stamp"`
Tag string `json:"tag"`
}
// Identity of a release asset: the digest if the forge publishes one (GitHub
// does), otherwise its upload time - this is what makes rolling tags such as
// "nightly" detectable.
func assetStamp(a *Asset) string {
switch {
case a.Digest != "":
return a.Digest
case a.UpdatedAt != "":
return "t:" + a.UpdatedAt
case a.CreatedAt != "":
return "t:" + a.CreatedAt
}
return fmt.Sprintf("s:%d", a.Size)
}
func targetCurrent(t target, tag string, a *Asset, spec *Spec) bool {
if st, err := os.Stat(t.dest); err != nil || !st.Mode().IsRegular() {
return false
}
var s state
if err := readJSON(stateFile(t.dest), &s); err == nil && s.Tag != "" {
if s.Tag != tag || s.Asset != a.Name || s.Stamp != assetStamp(a) {
return false
}
// A locally replaced binary counts as out of date.
same := s.BinarySHA256 == fileSHA256(t.dest)
if !same {
verbose("%s: state matches but binary differs", t.dest)
}
return same
}
// No state yet (first run after an install by other means): ask the binary.
v := installedVersion(t.dest, spec)
verbose("%s: no state file, binary reports %q", t.dest, v)
return v != "" && v == normVer(tag)
}
func installedTag(t target) string {
var s state
if err := readJSON(stateFile(t.dest), &s); err == nil && s.Tag != "" {
return s.Tag
}
if st, err := os.Stat(t.dest); err != nil || !st.Mode().IsRegular() {
return "-"
}
if v := installedVersion(t.dest, &Spec{}); v != "" {
return v
}
return "unknown"
}
func writeState(t target, c *forgeCtx, tag string, a *Asset) error {
if err := os.MkdirAll(stateDir(), 0o755); err != nil {
return fmt.Errorf("cannot create %s: %w", stateDir(), err)
}
return writeJSON(stateFile(t.dest), state{
Asset: a.Name,
AssetSize: a.Size,
Binary: t.out,
BinarySHA256: fileSHA256(t.dest),
Dest: t.dest,
Forge: c.forge,
InstalledAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
Repo: fmt.Sprintf("%s/%s/%s", c.base, c.owner, c.repo),
Stamp: assetStamp(a),
Tag: tag,
})
}
var versionRe = regexp.MustCompile(`\d+\.\d+(\.\d+)*([-+][\w.]+)?`)
// Legacy fallback: ask the binary for its version (used when no state exists).
func installedVersion(path string, spec *Spec) string {
if st, err := os.Stat(path); err != nil || st.Mode()&0o111 == 0 {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, path, spec.versionFlag())
cmd.Stdin = nil
out, _ := cmd.CombinedOutput() // a non-zero exit still often prints the version
return normVer(versionRe.FindString(string(out)))
}
// ==============================================================================
// Installing
// ==============================================================================
// Leftovers from an interrupted install must not linger next to the binary.
var temps struct {
sync.Mutex
paths []string
}
func addTemp(p string) {
temps.Lock()
defer temps.Unlock()
temps.paths = append(temps.paths, p)
}
func dropTemp(p string) {
temps.Lock()
defer temps.Unlock()
for i, q := range temps.paths {
if q == p {
temps.paths = append(temps.paths[:i], temps.paths[i+1:]...)
return
}
}
}
func cleanupTemps() {
temps.Lock()
defer temps.Unlock()
for _, p := range temps.paths {
os.Remove(p)
}
temps.paths = nil
}
func installAtomic(src, dest string) error {
dir := filepath.Dir(dest)
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return fmt.Errorf("target directory %s does not exist", dir)
}
if !writable(dir) {
return fmt.Errorf("no write permission in %s (use sudo, or pick another --install path)", dir)
}
tmp := fmt.Sprintf("%s.new.%d", dest, os.Getpid())
addTemp(tmp)
if err := copyFile(src, tmp, 0o755); err != nil {
os.Remove(tmp)
dropTemp(tmp)
return err
}
// rename is atomic and works even while dest is currently running.
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
dropTemp(tmp)
return fmt.Errorf("cannot replace %s: %w", dest, err)
}
dropTemp(tmp)
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("cannot read %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return fmt.Errorf("cannot write %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return fmt.Errorf("error writing %s: %w", dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("error writing %s: %w", dst, err)
}
return os.Chmod(dst, mode)
}
func stripQuarantine(dest string) {
if runtime.GOOS != "darwin" {
return
}
if _, err := os.Stat("/usr/bin/xattr"); err != nil {
return
}
cmd := exec.Command("/usr/bin/xattr", "-d", "com.apple.quarantine", dest)
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
cmd.Run() // absent attribute is not an error worth reporting
}
func fileSHA256(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}