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)) }