373 lines
9.6 KiB
Go
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))
|
|
}
|