initial commit [141.14.140.180,mike]
This commit is contained in:
+372
@@ -0,0 +1,372 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user