initial commit [141.14.140.180,mike]
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
bin/
|
||||
upd
|
||||
upd-*-*
|
||||
.mgshrc
|
||||
@@ -0,0 +1,104 @@
|
||||
# upd (Go)
|
||||
|
||||
A port of the Perl `upd` one directory up. Same command line, same config file,
|
||||
same state and cache files - the two can be used interchangeably on the same
|
||||
machine.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
builds every platform into `./bin`, statically linked and stripped, and bumps
|
||||
the patch version by 0.0.1 on each run. `version.txt` holds the version just
|
||||
built; the same number goes into the binaries via `-ldflags -X main.version`,
|
||||
so `upd --version` and the release tag always agree. One platform only:
|
||||
|
||||
```sh
|
||||
PLATFORMS="linux/amd64" ./build.sh
|
||||
```
|
||||
|
||||
The asset names in `./bin` - `upd-<goos>-<goarch>` - are exactly what
|
||||
`--update` looks for in a release, so a release is `./bin` uploaded as it is.
|
||||
A plain `go build -o ~/bin/upd .` still works; it just reports the fallback
|
||||
version from `main.go`.
|
||||
|
||||
`go test ./...` runs the asset selection against the release JSON in
|
||||
`../t/corpus`, the same corpus `t/select.t` uses, and fails if the two
|
||||
implementations start disagreeing.
|
||||
|
||||
## Updating itself
|
||||
|
||||
upd installs itself the same way it installs everything else - same forge
|
||||
layer, same CA fallback, same checksum check, same atomic replace:
|
||||
|
||||
```sh
|
||||
upd --version # what is running
|
||||
upd --check-update # look, change nothing
|
||||
upd --update # download the newest release and replace the binary
|
||||
```
|
||||
|
||||
`--update` replaces the file the running binary actually is, following a
|
||||
symlink into `./bin` to the file behind it. It refuses before downloading if
|
||||
that directory is not writable, and it runs the downloaded binary once with
|
||||
`--version` before letting it take over, so a truncated file or one for the
|
||||
wrong platform never replaces a working one. `--force` reinstalls the current
|
||||
version, `--dry-run` says what it would do.
|
||||
|
||||
Beyond that, an ordinary run looks for a new release once a day, in the
|
||||
background, and mentions it on stderr:
|
||||
|
||||
```
|
||||
Note: upd 2.0.7 is available, run 'upd --update'.
|
||||
```
|
||||
|
||||
The look never happens in the foreground - the run itself is never slowed down
|
||||
or made to depend on the network - and never when stderr is not a terminal, so
|
||||
cron and pipelines stay silent. `UPD_NO_UPDATE_CHECK=1` switches it off
|
||||
altogether. The note lives in `~/.cache/upd/selfupdate.json`.
|
||||
|
||||
The mechanism is the one in `dx`; here it goes through upd's own machinery
|
||||
instead of bringing its own HTTP client.
|
||||
|
||||
## Why a port
|
||||
|
||||
The Perl version needs curl or wget for HTTPS, which is exactly what breaks on
|
||||
an older system: a curl linked against OpenSSL 1.0.x cannot complete a
|
||||
handshake with a server that requires TLS 1.2, and there is nothing upd can do
|
||||
about it beyond falling back to wget. The Go binary brings its own TLS stack,
|
||||
so the transport question disappears - along with `--stderr` juggling, exit
|
||||
code translation and the wget fallback.
|
||||
|
||||
## Certificates
|
||||
|
||||
Verification uses the system CA store. If that store does not know the issuer -
|
||||
the usual case on a machine whose `ca-certificates` package predates Let's
|
||||
Encrypt's ISRG roots - upd says so once and retries with the Mozilla CA list
|
||||
embedded in `ca-bundle.pem`, so the binary stays self-sufficient. Refresh that
|
||||
file with:
|
||||
|
||||
```sh
|
||||
curl -o ca-bundle.pem https://curl.se/ca/cacert.pem
|
||||
```
|
||||
|
||||
`--cacert FILE` (or `$UPD_CACERT`) replaces the system store, for a private CA.
|
||||
`--insecure` skips verification altogether and warns on every run. Both switch
|
||||
the automatic fallback off: an explicit choice stays the choice.
|
||||
|
||||
## Differences
|
||||
|
||||
| | Perl | Go |
|
||||
|---|---|---|
|
||||
| TLS | curl / wget / IO::Socket::SSL | built in |
|
||||
| CA roots | whatever the system has | system, with the Mozilla list as fallback |
|
||||
| `--cacert`, `--insecure` | – | yes |
|
||||
| `tar`, `unzip` | external | in-process |
|
||||
| `.gz`, `.bz2` | external | in-process |
|
||||
| `.xz`, `.zst`, `.lz4`, `.7z` | external | external (unchanged) |
|
||||
| Archive paths | trusted to `tar` | checked against traversal |
|
||||
| `version-flag=` in the config file | overwritten by its own default | honoured |
|
||||
|
||||
Behaviour that deliberately stayed identical: asset scoring (same picks on the
|
||||
whole corpus), the `--install` file-or-directory rule, state and cache file
|
||||
layout, exit codes 0/1/2/10.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/bzip2"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Single-file compression that Go does not carry in its standard library. Each
|
||||
// entry lists the tools that can expand it, in order of preference; the first
|
||||
// one found wins. All of them write to stdout with the flags given.
|
||||
var external = map[string]struct {
|
||||
tools []string
|
||||
args []string
|
||||
}{
|
||||
"xz": {[]string{"xz", "unxz"}, []string{"-dc"}},
|
||||
"lzma": {[]string{"xz", "unxz", "lzma"}, []string{"-dc"}},
|
||||
"zst": {[]string{"zstd", "unzstd"}, []string{"-dcq"}},
|
||||
"lz4": {[]string{"lz4", "unlz4"}, []string{"-dcq"}},
|
||||
}
|
||||
|
||||
type decomp struct {
|
||||
prog string
|
||||
args []string
|
||||
}
|
||||
|
||||
// decompressor reports how to expand ext, or nil if this machine cannot.
|
||||
// gz and bz2 are handled in-process and need no helper at all.
|
||||
func decompressor(ext string) *decomp {
|
||||
switch ext {
|
||||
case "gz", "bz2":
|
||||
return &decomp{} // stdlib, no external program
|
||||
}
|
||||
e, ok := external[ext]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, t := range e.tools {
|
||||
if p, err := exec.LookPath(t); err == nil {
|
||||
return &decomp{prog: p, args: e.args}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sevenZip() string {
|
||||
for _, t := range []string{"7zz", "7z", "7za"} {
|
||||
if p, err := exec.LookPath(t); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reader wraps f in whatever ext needs, shelling out only when the standard
|
||||
// library has no codec for it.
|
||||
func decompressReader(ext, path string, f *os.File) (io.Reader, func() error, error) {
|
||||
nop := func() error { return nil }
|
||||
switch ext {
|
||||
case "gz":
|
||||
zr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return zr, zr.Close, nil
|
||||
case "bz2":
|
||||
return bzip2.NewReader(f), nop, nil
|
||||
}
|
||||
|
||||
d := decompressor(ext)
|
||||
if d == nil {
|
||||
return nil, nil, fmt.Errorf("cannot unpack .%s - install %s or pick another asset with --asset/--pattern",
|
||||
ext, strings.Join(external[ext].tools, "/"))
|
||||
}
|
||||
cmd := exec.Command(d.prog, append(append([]string{}, d.args...), path)...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stderr = os.Stderr
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, func() error {
|
||||
io.Copy(io.Discard, out) // drain, or the child gets EPIPE
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("failed to decompress %s (%s: %w)", filepath.Base(path), filepath.Base(d.prog), err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractIfArchive unpacks into a fresh directory below tmpdir and returns its
|
||||
// path, or "" if the asset is a bare binary (then the download is the binary).
|
||||
func extractIfArchive(file, tmpdir string) (string, error) {
|
||||
lc := strings.ToLower(filepath.Base(file))
|
||||
out := filepath.Join(tmpdir, "x")
|
||||
|
||||
switch {
|
||||
case tarballRe.MatchString(lc):
|
||||
if err := os.Mkdir(out, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, untar(file, out, tarCodec(lc))
|
||||
|
||||
case strings.HasSuffix(lc, ".zip"):
|
||||
if err := os.Mkdir(out, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, unzip(file, out)
|
||||
|
||||
case strings.HasSuffix(lc, ".7z"):
|
||||
z := sevenZip()
|
||||
if z == "" {
|
||||
return "", fmt.Errorf("7z not found, but needed for %s", lc)
|
||||
}
|
||||
if err := os.Mkdir(out, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command(z, "x", "-y", "-o"+out, file)
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to extract %s: %w", lc, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// A single compressed file, e.g. restic_0.19.1_darwin_arm64.bz2 - the
|
||||
// binary itself, just squeezed.
|
||||
if m := singleRe.FindStringSubmatch(lc); m != nil {
|
||||
ext := m[1]
|
||||
if err := os.Mkdir(out, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
plain := filepath.Join(out, stem)
|
||||
if err := decompressFile(ext, file, plain); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return "", nil // bare binary
|
||||
}
|
||||
|
||||
// tarCodec maps a tarball suffix to the compression wrapped around the tar.
|
||||
func tarCodec(lc string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(lc, ".tar.gz"), strings.HasSuffix(lc, ".tgz"):
|
||||
return "gz"
|
||||
case strings.HasSuffix(lc, ".tar.bz2"), strings.HasSuffix(lc, ".tbz"):
|
||||
return "bz2"
|
||||
case strings.HasSuffix(lc, ".tar.xz"), strings.HasSuffix(lc, ".txz"):
|
||||
return "xz"
|
||||
case strings.HasSuffix(lc, ".tar.zst"), strings.HasSuffix(lc, ".tzst"):
|
||||
return "zst"
|
||||
}
|
||||
return "" // plain .tar
|
||||
}
|
||||
|
||||
func decompressFile(ext, src, dst string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r, closer, err := decompressReader(ext, src, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return err
|
||||
}
|
||||
return closer()
|
||||
}
|
||||
|
||||
func untar(file, dest, codec string) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var r io.Reader = f
|
||||
closer := func() error { return nil }
|
||||
if codec != "" {
|
||||
r, closer, err = decompressReader(codec, file, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tr := tar.NewReader(r)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract %s: %w", filepath.Base(file), err)
|
||||
}
|
||||
path, ok := safeJoin(dest, h.Name)
|
||||
if !ok {
|
||||
verbose("skipping %s (path escapes the archive)", h.Name)
|
||||
continue
|
||||
}
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := writeFileFrom(path, tr, os.FileMode(h.Mode).Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Symlinks and devices are never the binary we are after.
|
||||
verbose("skipping %s (not a regular file)", h.Name)
|
||||
}
|
||||
}
|
||||
return closer()
|
||||
}
|
||||
|
||||
func unzip(file, dest string) error {
|
||||
zr, err := zip.OpenReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract %s: %w", filepath.Base(file), err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, f := range zr.File {
|
||||
path, ok := safeJoin(dest, f.Name)
|
||||
if !ok {
|
||||
verbose("skipping %s (path escapes the archive)", f.Name)
|
||||
continue
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !f.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writeFileFrom(path, rc, f.Mode().Perm())
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeJoin keeps an archive from writing outside its own directory.
|
||||
func safeJoin(dest, name string) (string, bool) {
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(dest, clean), true
|
||||
}
|
||||
|
||||
func writeFileFrom(path string, r io.Reader, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
|
||||
// findInTree looks for a file called name; failing that, for the single
|
||||
// executable in the tree.
|
||||
func findInTree(root, name string) string {
|
||||
var files []string
|
||||
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil && d.Type().IsRegular() {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
for _, p := range files {
|
||||
if b := filepath.Base(p); b == name || b == name+".exe" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
var execs []string
|
||||
for _, p := range files {
|
||||
if looksExecutable(p) {
|
||||
execs = append(execs, p)
|
||||
}
|
||||
}
|
||||
if len(execs) == 1 {
|
||||
return execs[0]
|
||||
}
|
||||
verbose("archive contains %d files, %d of them executable", len(files), len(execs))
|
||||
return ""
|
||||
}
|
||||
|
||||
// Magic bytes beat guessing by file size: ELF, Mach-O (incl. fat), PE, script.
|
||||
func looksExecutable(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m := make([]byte, 4)
|
||||
n, _ := io.ReadFull(f, m)
|
||||
if n < 2 {
|
||||
return false
|
||||
}
|
||||
m = m[:n]
|
||||
switch {
|
||||
case bytes.HasPrefix(m, []byte("\x7fELF")):
|
||||
return true
|
||||
case bytes.HasPrefix(m, []byte{0xcf, 0xfa, 0xed, 0xfe}), // Mach-O 64
|
||||
bytes.HasPrefix(m, []byte{0xce, 0xfa, 0xed, 0xfe}), // Mach-O 32
|
||||
bytes.HasPrefix(m, []byte{0xca, 0xfe, 0xba, 0xbe}), // fat
|
||||
bytes.HasPrefix(m, []byte{0xbe, 0xba, 0xfe, 0xca}):
|
||||
return true
|
||||
case bytes.HasPrefix(m, []byte("MZ")):
|
||||
return true
|
||||
case bytes.HasPrefix(m, []byte("#!")):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Aliases commonly used in release asset names, including Rust target triples.
|
||||
func osAlias(o string) []string {
|
||||
switch o {
|
||||
case "darwin":
|
||||
return []string{"darwin", "macos", "osx", "mac", "apple"}
|
||||
case "windows":
|
||||
return []string{"windows", "win"}
|
||||
}
|
||||
return []string{o}
|
||||
}
|
||||
|
||||
func archAlias(a string) []string {
|
||||
switch a {
|
||||
case "amd64":
|
||||
return []string{"amd64", "x86_64", "x64", "64bit"}
|
||||
case "arm64":
|
||||
return []string{"arm64", "aarch64"}
|
||||
case "386":
|
||||
return []string{"386", "i386", "i686", "x86", "32bit"}
|
||||
case "arm":
|
||||
return []string{"arm", "armv7", "armv6", "armhf"}
|
||||
case "riscv64":
|
||||
return []string{"riscv64", "riscv64gc"}
|
||||
}
|
||||
return []string{a}
|
||||
}
|
||||
|
||||
var (
|
||||
// Checksums, signatures and OS packages are not plain binaries.
|
||||
notBinaryRe = regexp.MustCompile(`\.(sha\d*|sha256sum|md5|asc|sig|pem|sbom|json|txt|deb|rpm|apk|dmg|pkg|msi|snap|flatpak|appimage)$`)
|
||||
// Cross-compile targets whose triples contain a host OS token:
|
||||
// aarch64-linux-android is Android, not Linux.
|
||||
foreignRe = regexp.MustCompile(`(^|[^a-z0-9])(android\w*|ios|wasi|wasm\w*|emscripten)([^a-z0-9]|$)`)
|
||||
archiveRe = regexp.MustCompile(`\.(tar\.gz|tgz|zip|tar\.xz|tar\.bz2|tar\.zst|tzst)$`)
|
||||
noiseRe = regexp.MustCompile(`debug|symbols|static-pie|profile`)
|
||||
tarballRe = regexp.MustCompile(`\.(tar\.gz|tgz|tar\.bz2|tbz|tar\.xz|txz|tar\.zst|tzst|tar)$`)
|
||||
singleRe = regexp.MustCompile(`\.(gz|bz2|xz|zst|lz4|lzma)$`)
|
||||
refuseRe = regexp.MustCompile(`\.(rar|dmg|pkg|msi|deb|rpm)$`)
|
||||
widerArm = regexp.MustCompile(`arm64|aarch64`)
|
||||
wider386 = regexp.MustCompile(`x86[_-]?64|amd64`)
|
||||
|
||||
win64Re = regexp.MustCompile(`(^|[^a-z0-9])win64($|[^a-z0-9])`)
|
||||
win32Re = regexp.MustCompile(`(^|[^a-z0-9])win32($|[^a-z0-9])`)
|
||||
)
|
||||
|
||||
// Compound tokens that name OS and architecture in one word. Rewritten before
|
||||
// matching so the normal token rules apply. "win32" is Node-speak for Windows
|
||||
// in general (pnpm-win32-x64.zip), so it contributes no architecture.
|
||||
func normalizeTokens(n string) string {
|
||||
n = win64Re.ReplaceAllString(n, "${1}windows-amd64${2}")
|
||||
n = win32Re.ReplaceAllString(n, "${1}windows${2}")
|
||||
return n
|
||||
}
|
||||
|
||||
// tokenRe matches any of the words as a token, i.e. not glued to letters or
|
||||
// digits. RE2 has no look-around, so the boundaries are ordinary characters.
|
||||
func tokenRe(words []string) *regexp.Regexp {
|
||||
quoted := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
quoted[i] = regexp.QuoteMeta(w)
|
||||
}
|
||||
return regexp.MustCompile(`(^|[^a-z0-9])(` + strings.Join(quoted, "|") + `)([^a-z0-9]|$)`)
|
||||
}
|
||||
|
||||
// Can this machine turn the asset into a binary? tar, zip, gzip and bzip2 are
|
||||
// handled in-process; the rest needs a helper on $PATH.
|
||||
func unpackable(n string) bool {
|
||||
n = strings.ToLower(n)
|
||||
switch {
|
||||
case regexp.MustCompile(`\.(tar\.gz|tgz|tar\.bz2|tbz|tar|zip)$`).MatchString(n):
|
||||
return true
|
||||
case regexp.MustCompile(`\.(tar\.xz|txz)$`).MatchString(n):
|
||||
return decompressor("xz") != nil
|
||||
case regexp.MustCompile(`\.(tar\.zst|tzst)$`).MatchString(n):
|
||||
return decompressor("zst") != nil
|
||||
case strings.HasSuffix(n, ".7z"):
|
||||
return sevenZip() != ""
|
||||
case singleRe.MatchString(n):
|
||||
return decompressor(singleRe.FindStringSubmatch(n)[1]) != nil
|
||||
case refuseRe.MatchString(n):
|
||||
return false
|
||||
}
|
||||
return true // bare binary or an unversioned name
|
||||
}
|
||||
|
||||
// Glibc vs musl builds: pick what this system actually runs.
|
||||
var muslOnce = sync.OnceValue(func() bool {
|
||||
if _, err := os.Stat("/etc/alpine-release"); err == nil {
|
||||
return true
|
||||
}
|
||||
ldd, err := exec.LookPath("ldd")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
out, _ := exec.Command(ldd, "--version").CombinedOutput()
|
||||
return strings.Contains(strings.ToLower(string(out)), "musl")
|
||||
})
|
||||
|
||||
type candidate struct {
|
||||
score int
|
||||
pref int
|
||||
length int
|
||||
asset *Asset
|
||||
}
|
||||
|
||||
// pickAsset mirrors the Perl scoring one to one - the corpus test in
|
||||
// asset_test.go checks that the two keep agreeing.
|
||||
func pickAsset(assets []Asset, bin, wantOS, wantArch string, spec *Spec) (*Asset, error) {
|
||||
if spec.Asset != "" {
|
||||
for i := range assets {
|
||||
if assets[i].Name == spec.Asset {
|
||||
return &assets[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("asset %q is not part of the release", spec.Asset)
|
||||
}
|
||||
if spec.Pattern != "" {
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --pattern: %w", err)
|
||||
}
|
||||
for i := range assets {
|
||||
if re.MatchString(assets[i].Name) {
|
||||
return &assets[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
arches := archAlias(wantArch)
|
||||
// Universal macOS builds serve both architectures.
|
||||
if wantOS == "darwin" {
|
||||
arches = append(arches, "universal", "universal2")
|
||||
}
|
||||
osRe, archRe := tokenRe(osAlias(wantOS)), tokenRe(arches)
|
||||
|
||||
var cands []candidate
|
||||
var rejected []string
|
||||
for i := range assets {
|
||||
as := &assets[i]
|
||||
n := strings.ToLower(as.Name)
|
||||
m := normalizeTokens(n)
|
||||
|
||||
switch {
|
||||
case notBinaryRe.MatchString(n):
|
||||
rejected = append(rejected, n+" (not a binary)")
|
||||
continue
|
||||
case foreignRe.MatchString(m):
|
||||
rejected = append(rejected, n+" (foreign target)")
|
||||
continue
|
||||
case !osRe.MatchString(m):
|
||||
rejected = append(rejected, n+" (os)")
|
||||
continue
|
||||
case !archRe.MatchString(m):
|
||||
rejected = append(rejected, n+" (arch)")
|
||||
continue
|
||||
// Keep 32-bit "arm" off "arm64" and "386" off "x86_64".
|
||||
case wantArch == "arm" && widerArm.MatchString(m),
|
||||
wantArch == "386" && wider386.MatchString(m),
|
||||
wantArch == "amd64" && widerArm.MatchString(m):
|
||||
rejected = append(rejected, n+" (wider arch)")
|
||||
continue
|
||||
}
|
||||
|
||||
score := 0
|
||||
if bin != "" && strings.HasPrefix(n, strings.ToLower(bin)) {
|
||||
score += 10 // named after the binary
|
||||
}
|
||||
if archiveRe.MatchString(n) || !strings.Contains(n, ".") {
|
||||
score += 3
|
||||
}
|
||||
if noiseRe.MatchString(n) {
|
||||
score -= 5
|
||||
}
|
||||
// Only avoid formats this machine has no tool for - restic, for one,
|
||||
// ships nothing but .bz2, and that is handled in-process.
|
||||
if !unpackable(n) {
|
||||
score -= 8
|
||||
}
|
||||
|
||||
// Toolchain preference is a tie-break, not a penalty: a project that
|
||||
// ships musl only (or mingw only) must not be downranked for it.
|
||||
pref := 0
|
||||
switch {
|
||||
case wantOS == "linux" && (strings.Contains(n, "musl") || strings.Contains(n, "gnu")):
|
||||
libc := "gnu"
|
||||
if muslOnce() {
|
||||
libc = "musl"
|
||||
}
|
||||
if !strings.Contains(n, libc) {
|
||||
pref = 1
|
||||
}
|
||||
case wantOS == "windows" && (strings.Contains(n, "msvc") || strings.Contains(n, "gnu")):
|
||||
if !strings.Contains(n, "msvc") { // msvc is the normal Windows build
|
||||
pref = 1
|
||||
}
|
||||
}
|
||||
cands = append(cands, candidate{score: score, pref: pref, length: len(n), asset: as})
|
||||
}
|
||||
|
||||
// Best score, then preferred toolchain, then the shortest name (which
|
||||
// avoids special variants like -baseline or -static).
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
a, b := cands[i], cands[j]
|
||||
if a.score != b.score {
|
||||
return a.score > b.score
|
||||
}
|
||||
if a.pref != b.pref {
|
||||
return a.pref < b.pref
|
||||
}
|
||||
return a.length < b.length
|
||||
})
|
||||
|
||||
if opt.verbose {
|
||||
for _, c := range cands {
|
||||
verbose("candidate score=%-3d pref=%d %s", c.score, c.pref, c.asset.Name)
|
||||
}
|
||||
for _, r := range rejected {
|
||||
verbose("rejected: %s", r)
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return cands[0].asset, nil
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Run the asset selection against the real release JSON in t/corpus/, offline.
|
||||
// Every pick is checked for tokens that contradict the requested platform, so
|
||||
// a wrong choice fails loudly instead of just looking plausible. This is the
|
||||
// Go twin of t/select.t and must reach the same verdict.
|
||||
|
||||
var platforms = [][2]string{
|
||||
{"darwin", "arm64"}, {"darwin", "amd64"}, {"linux", "amd64"},
|
||||
{"linux", "arm64"}, {"windows", "amd64"},
|
||||
}
|
||||
|
||||
// Tokens that must never appear in a pick for the given platform.
|
||||
var badOS = map[string][]string{
|
||||
"darwin": {"linux", "windows", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
||||
"linux": {"darwin", "macos", "osx", "apple", "windows", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
||||
"windows": {"linux", "darwin", "macos", "osx", "apple", "freebsd", "netbsd", "openbsd", "solaris", "aix"},
|
||||
}
|
||||
|
||||
var badArch = map[string][]string{
|
||||
"amd64": {"arm64", "aarch64", "armv7", "armhf", "i686", "i386", "riscv", "riscv64", "ppc64", "ppc64le", "s390x", "sparc64", "mips", "mips64"},
|
||||
"arm64": {"amd64", "x86_64", "i686", "i386", "riscv", "riscv64", "ppc64", "ppc64le", "s390x", "sparc64", "mips", "mips64"},
|
||||
}
|
||||
|
||||
// Cross-compile targets that are never the host.
|
||||
var foreign = []string{"android", "androideabi", "ios", "wasi", "wasm", "wasm32", "emscripten"}
|
||||
|
||||
var notABinary = regexp.MustCompile(`\.(deb|rpm|pkg|dmg|msi|apk|snap|appimage|asc|sig|sha256)$`)
|
||||
|
||||
func corpusDir(t *testing.T) string {
|
||||
dir := filepath.Join("..", "t", "corpus")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Skipf("no corpus at %s", dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func tok(name, word string) bool {
|
||||
return regexp.MustCompile(`(^|[^a-z0-9])` + regexp.QuoteMeta(word) + `([^a-z0-9]|$)`).MatchString(name)
|
||||
}
|
||||
|
||||
func TestCorpusSelection(t *testing.T) {
|
||||
dir := corpusDir(t)
|
||||
|
||||
bins := map[string]string{}
|
||||
if body, err := os.ReadFile(filepath.Join(dir, "binaries.json")); err == nil {
|
||||
json.Unmarshal(body, &bins)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*__*.json"))
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no corpus files in %s", dir)
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
stats := map[string]int{}
|
||||
gaps := map[string][]string{}
|
||||
|
||||
for _, file := range files {
|
||||
slug := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||
repo := strings.Replace(slug, "__", "/", 1)
|
||||
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
var rel Release
|
||||
if err := json.Unmarshal(body, &rel); err != nil {
|
||||
t.Fatalf("%s: %v", file, err)
|
||||
}
|
||||
if len(rel.Assets) == 0 {
|
||||
t.Errorf("%s: no assets in release", repo)
|
||||
continue
|
||||
}
|
||||
|
||||
bin := bins[repo]
|
||||
if bin == "" {
|
||||
bin = repo[strings.LastIndex(repo, "/")+1:]
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
goos, goarch := p[0], p[1]
|
||||
hit, err := pickAsset(rel.Assets, bin, goos, goarch, &Spec{})
|
||||
if err != nil {
|
||||
t.Errorf("%s %s/%s: %v", repo, goos, goarch, err)
|
||||
continue
|
||||
}
|
||||
if hit == nil {
|
||||
stats["no match"]++
|
||||
gaps[repo] = append(gaps[repo], goos+"/"+goarch)
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.ToLower(hit.Name)
|
||||
flag := ""
|
||||
for _, w := range badOS[goos] {
|
||||
if tok(name, w) {
|
||||
flag = "WRONG OS (" + w + ")"
|
||||
}
|
||||
}
|
||||
for _, w := range badArch[goarch] {
|
||||
if tok(name, w) {
|
||||
flag = "WRONG ARCH (" + w + ")"
|
||||
}
|
||||
}
|
||||
for _, w := range foreign {
|
||||
if tok(name, w) {
|
||||
flag = "FOREIGN TARGET (" + w + ")"
|
||||
}
|
||||
}
|
||||
if flag == "" && goos != "windows" && strings.HasSuffix(name, ".exe") {
|
||||
flag = "EXE ON UNIX"
|
||||
}
|
||||
if flag == "" && notABinary.MatchString(name) {
|
||||
flag = "NOT A BINARY"
|
||||
}
|
||||
if flag == "" && !unpackable(name) {
|
||||
flag = "UNPACKABLE?"
|
||||
}
|
||||
if flag != "" {
|
||||
t.Errorf("%s %s/%s -> %s [%s]", repo, goos, goarch, hit.Name, flag)
|
||||
stats[flag]++
|
||||
continue
|
||||
}
|
||||
stats["ok"]++
|
||||
}
|
||||
}
|
||||
|
||||
var summary []string
|
||||
for _, k := range sortedKeys(stats) {
|
||||
summary = append(summary, fmt.Sprintf("%s=%d", k, stats[k]))
|
||||
}
|
||||
t.Logf("Totals: %s", strings.Join(summary, ", "))
|
||||
if len(gaps) > 0 {
|
||||
var lines []string
|
||||
for _, repo := range sortedKeys(gaps) {
|
||||
lines = append(lines, fmt.Sprintf("%s (%s)", repo, strings.Join(gaps[repo], " ")))
|
||||
}
|
||||
t.Logf("Unmatched: %s", strings.Join(lines, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// The Perl version reaches these totals on the same corpus; a change here is a
|
||||
// change in behaviour and wants a look, not a blind update.
|
||||
func TestCorpusTotalsMatchPerl(t *testing.T) {
|
||||
dir := corpusDir(t)
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "*__*.json"))
|
||||
|
||||
bins := map[string]string{}
|
||||
if body, err := os.ReadFile(filepath.Join(dir, "binaries.json")); err == nil {
|
||||
json.Unmarshal(body, &bins)
|
||||
}
|
||||
|
||||
ok, misses := 0, 0
|
||||
for _, file := range files {
|
||||
slug := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||
repo := strings.Replace(slug, "__", "/", 1)
|
||||
body, _ := os.ReadFile(file)
|
||||
var rel Release
|
||||
if json.Unmarshal(body, &rel) != nil {
|
||||
continue
|
||||
}
|
||||
bin := bins[repo]
|
||||
if bin == "" {
|
||||
bin = repo[strings.LastIndex(repo, "/")+1:]
|
||||
}
|
||||
for _, p := range platforms {
|
||||
hit, err := pickAsset(rel.Assets, bin, p[0], p[1], &Spec{})
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Fatalf("%s: %v", repo, err)
|
||||
case hit == nil:
|
||||
misses++
|
||||
default:
|
||||
ok++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok != 141 || misses != 9 {
|
||||
t.Errorf("corpus totals drifted: ok=%d no-match=%d, want ok=141 no-match=9", ok, misses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepo(t *testing.T) {
|
||||
cases := []struct{ in, base, owner, repo string }{
|
||||
{"https://github.com/sxyazi/yazi", "https://github.com", "sxyazi", "yazi"},
|
||||
{"https://git.micw.org/mike/dns", "https://git.micw.org", "mike", "dns"},
|
||||
{"https://git.micw.org/mike/dns.git", "https://git.micw.org", "mike", "dns"},
|
||||
{"https://git.micw.org/mike/dns/", "https://git.micw.org", "mike", "dns"},
|
||||
{"git.micw.org/mike/dns", "https://git.micw.org", "mike", "dns"},
|
||||
{"https://example.org/gitea/mike/dns", "https://example.org/gitea", "mike", "dns"},
|
||||
{"http://localhost:3000/mike/dns", "http://localhost:3000", "mike", "dns"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
base, owner, repo, err := parseRepo(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if base != c.base || owner != c.owner || repo != c.repo {
|
||||
t.Errorf("%s -> %s %s %s, want %s %s %s", c.in, base, owner, repo, c.base, c.owner, c.repo)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"https://github.com/onlyowner", "https://github.com"} {
|
||||
if _, _, _, err := parseRepo(bad); err == nil {
|
||||
t.Errorf("%s: expected an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgeDetection(t *testing.T) {
|
||||
cases := []struct{ base, forge, api string }{
|
||||
{"https://github.com", "github", "https://api.github.com"},
|
||||
{"https://git.micw.org", "gitea", "https://git.micw.org/api/v1"},
|
||||
{"https://example.org/gitea", "gitea", "https://example.org/gitea/api/v1"},
|
||||
{"https://notgithub.com", "gitea", "https://notgithub.com/api/v1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
f, err := detectForge(c.base, "")
|
||||
if err != nil || f != c.forge {
|
||||
t.Errorf("%s -> %q (%v), want %q", c.base, f, err, c.forge)
|
||||
}
|
||||
if got := apiBase(c.base, f); got != c.api {
|
||||
t.Errorf("%s -> %s, want %s", c.base, got, c.api)
|
||||
}
|
||||
}
|
||||
if _, err := detectForge("https://x", "gitlab"); err == nil {
|
||||
t.Error("expected an error for an unknown forge")
|
||||
}
|
||||
// GitHub Enterprise needs the override and lands on /api/v3.
|
||||
if got := apiBase("https://gh.corp.example", "github"); got != "https://gh.corp.example/api/v3" {
|
||||
t.Errorf("enterprise API base: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
list, fallback string
|
||||
want []name
|
||||
}{
|
||||
{"", "yazi", []name{{"yazi", "yazi"}}},
|
||||
{"yazi,ya", "x", []name{{"yazi", "yazi"}, {"ya", "ya"}}},
|
||||
{"yazi:yazi-nightly", "x", []name{{"yazi", "yazi-nightly"}}},
|
||||
{"a , b", "x", []name{{"a", "a"}, {"b", "b"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseNames(c.list, c.fallback)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("%q -> %v, want %v", c.list, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("%q -> %v, want %v", c.list, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTokens(t *testing.T) {
|
||||
got := splitTokens(`https://x/y install=~/bin name="a b" pre`)
|
||||
want := []string{"https://x/y", "install=~/bin", "name=a b", "pre"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTokens(t *testing.T) {
|
||||
cases := [][2]string{
|
||||
{"pnpm-win32-x64.zip", "pnpm-windows-x64.zip"},
|
||||
{"tool-win64.zip", "tool-windows-amd64.zip"},
|
||||
{"winsomething-x64", "winsomething-x64"}, // not a token, left alone
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeTokens(c[0]); got != c[1] {
|
||||
t.Errorf("%s -> %s, want %s", c[0], got, c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFileLayout(t *testing.T) {
|
||||
t.Setenv("XDG_STATE_HOME", "/tmp/state")
|
||||
// Same recipe as the Perl version: sanitised path, dot, 8 hex characters.
|
||||
got := filepath.Base(stateFile("/home/mike/bin/fzf"))
|
||||
if !regexp.MustCompile(`^home_mike_bin_fzf\.[0-9a-f]{8}\.json$`).MatchString(got) {
|
||||
t.Errorf("unexpected state file name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeJoin(t *testing.T) {
|
||||
for _, bad := range []string{"../evil", "/etc/passwd", "a/../../evil"} {
|
||||
if _, ok := safeJoin("/dest", bad); ok {
|
||||
t.Errorf("%q should have been rejected", bad)
|
||||
}
|
||||
}
|
||||
if p, ok := safeJoin("/dest", "sub/bin/tool"); !ok || p != filepath.Join("/dest", "sub/bin/tool") {
|
||||
t.Errorf("safeJoin rejected a normal path: %s %v", p, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Build upd for the usual platforms into ./bin, auto-incrementing the patch
|
||||
# version by 0.0.1 on every build.
|
||||
#
|
||||
# version.txt holds the currently built version. Each run increments the patch
|
||||
# component, then builds every platform with that one version injected via
|
||||
# -ldflags, and writes it back. So version.txt always reflects the version of
|
||||
# the binaries just built, and all of them carry the same one.
|
||||
#
|
||||
# The asset names ./bin ends up with are exactly what selfupdate.go looks for
|
||||
# in a release: upd-<goos>-<goarch>. Upload the directory as it is.
|
||||
#
|
||||
# Override the platform list to build just one, or to add a platform:
|
||||
# PLATFORMS="linux/amd64" ./build.sh
|
||||
# PLATFORMS="linux/386 linux/arm64" ./build.sh
|
||||
#
|
||||
# Windows is not in the list: the run is wrapped in a SIGHUP handler and the
|
||||
# install path rules assume a Unix $PATH, so shipping it would promise more
|
||||
# than has been tested. PLATFORMS can add it.
|
||||
#
|
||||
# -s -w drops the symbol table and DWARF info, -trimpath keeps build paths out
|
||||
# of the binary; together they roughly halve it. Neither affects a panic trace.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64"}
|
||||
|
||||
V=$(cat version.txt 2>/dev/null || echo 2.0.0)
|
||||
|
||||
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 2.0.9 -> 2.0.10)
|
||||
MAJOR=${V%%.*}
|
||||
REST=${V#*.}
|
||||
MINOR=${REST%%.*}
|
||||
PATCH=${REST#*.}
|
||||
PATCH=$((PATCH + 1))
|
||||
NV="$MAJOR.$MINOR.$PATCH"
|
||||
|
||||
mkdir -p bin
|
||||
HOST="$(go env GOOS)/$(go env GOARCH)"
|
||||
|
||||
for p in $PLATFORMS; do
|
||||
os=${p%/*}
|
||||
arch=${p#*/}
|
||||
out="bin/upd-$os-$arch"
|
||||
|
||||
# CGO_ENABLED=0 throughout: it makes the cross builds work without a
|
||||
# toolchain per target and the binaries static - which is the point on the
|
||||
# old machines upd exists for. It also settles the one thing cgo would
|
||||
# change here: name resolution goes through Go's own resolver, not the
|
||||
# system one, and TLS never touched the C library to begin with.
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath -ldflags "-s -w -X main.version=$NV" -o "$out" .
|
||||
|
||||
if [ "$p" = "$HOST" ]; then
|
||||
ln -sf "upd-$os-$arch" bin/upd # the one for this machine
|
||||
echo " $out -> bin/upd"
|
||||
else
|
||||
echo " $out"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$NV" > version.txt
|
||||
echo "built upd v$NV"
|
||||
+2950
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Gitea and GitHub expose the same release JSON, only under different API
|
||||
// roots and with different auth headers.
|
||||
type forgeCtx struct {
|
||||
base string // scheme, host and any sub-path Gitea is mounted under
|
||||
owner string
|
||||
repo string
|
||||
forge string // "github" or "gitea"
|
||||
token string
|
||||
api string // .../repos/<owner>/<repo>
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Assets []Asset `json:"assets"`
|
||||
}
|
||||
|
||||
func newForgeCtx(spec *Spec) (*forgeCtx, error) {
|
||||
base, owner, repo, err := parseRepo(spec.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
forge, err := detectForge(base, spec.Forge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &forgeCtx{
|
||||
base: base,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
forge: forge,
|
||||
token: resolveToken(forge, spec.Token),
|
||||
}
|
||||
c.api = apiBase(base, forge) + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func parseRepo(repo string) (base, owner, name string, err error) {
|
||||
repo = strings.TrimRight(repo, "/")
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
if !strings.HasPrefix(repo, "http://") && !strings.HasPrefix(repo, "https://") {
|
||||
repo = "https://" + repo
|
||||
}
|
||||
u, e := url.Parse(repo)
|
||||
if e != nil || u.Host == "" {
|
||||
return "", "", "", fmt.Errorf("cannot parse repository URL: %s", repo)
|
||||
}
|
||||
// Anything before <owner>/<repo> is a base path (Gitea under a sub-path).
|
||||
seg := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
|
||||
if len(seg) < 2 {
|
||||
return "", "", "", fmt.Errorf("repository URL needs <owner>/<repo>: %s", repo)
|
||||
}
|
||||
owner, name = seg[len(seg)-2], seg[len(seg)-1]
|
||||
base = u.Scheme + "://" + u.Host
|
||||
if prefix := seg[:len(seg)-2]; len(prefix) > 0 {
|
||||
base += "/" + strings.Join(prefix, "/")
|
||||
}
|
||||
return base, owner, name, nil
|
||||
}
|
||||
|
||||
var githubHost = regexp.MustCompile(`(^|\.)github\.com$`)
|
||||
|
||||
func hostOf(base string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
|
||||
func detectForge(base, override string) (string, error) {
|
||||
if override != "" {
|
||||
f := strings.ToLower(override)
|
||||
if f != "github" && f != "gitea" {
|
||||
return "", fmt.Errorf("unknown forge %q (use github or gitea)", override)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
if githubHost.MatchString(hostOf(base)) {
|
||||
return "github", nil
|
||||
}
|
||||
return "gitea", nil
|
||||
}
|
||||
|
||||
func apiBase(base, forge string) string {
|
||||
if forge != "github" {
|
||||
return base + "/api/v1"
|
||||
}
|
||||
if githubHost.MatchString(hostOf(base)) {
|
||||
return "https://api.github.com"
|
||||
}
|
||||
return base + "/api/v3" // GitHub Enterprise
|
||||
}
|
||||
|
||||
func resolveToken(forge, explicit string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
env := []string{"UPD_TOKEN", "GITEA_TOKEN"}
|
||||
if forge == "github" {
|
||||
env = []string{"UPD_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"}
|
||||
}
|
||||
for _, k := range env {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *forgeCtx) authHeaders() map[string]string {
|
||||
if c.token == "" {
|
||||
return map[string]string{}
|
||||
}
|
||||
// GitHub wants "Bearer", Gitea wants "token".
|
||||
if c.forge == "github" {
|
||||
return map[string]string{"Authorization": "Bearer " + c.token}
|
||||
}
|
||||
return map[string]string{"Authorization": "token " + c.token}
|
||||
}
|
||||
|
||||
// Private GitHub assets are only reachable through the API URL; the browser
|
||||
// URL redirects to storage, where the Authorization header is dropped.
|
||||
func (c *forgeCtx) assetURL(a *Asset) (string, map[string]string) {
|
||||
if c.forge == "github" && c.token != "" && a.ID != 0 {
|
||||
return fmt.Sprintf("%s/releases/assets/%d", c.api, a.ID),
|
||||
map[string]string{"Accept": "application/octet-stream"}
|
||||
}
|
||||
return a.BrowserDownloadURL, map[string]string{}
|
||||
}
|
||||
|
||||
func (c *forgeCtx) releasesURL() string {
|
||||
if c.forge == "github" {
|
||||
return c.api + "/releases?per_page=50"
|
||||
}
|
||||
return c.api + "/releases?limit=50&draft=false"
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Release selection
|
||||
// ==============================================================================
|
||||
|
||||
func (c *forgeCtx) fetchRelease(spec *Spec) (*Release, []Release, error) {
|
||||
// A specific tag: both forges have a direct endpoint. Fall back to the list
|
||||
// so "26.5.6" also finds a tag named "v26.5.6".
|
||||
if spec.Tag != "" {
|
||||
body, err := c.apiGet(c.api+"/releases/tags/"+url.PathEscape(spec.Tag), true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if body != nil {
|
||||
var rel Release
|
||||
if json.Unmarshal(body, &rel) == nil && rel.TagName != "" {
|
||||
return &rel, nil, nil
|
||||
}
|
||||
}
|
||||
list, err := c.releaseList()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].TagName == spec.Tag {
|
||||
return &list[i], nil, nil
|
||||
}
|
||||
}
|
||||
for i := range list {
|
||||
if normVer(list[i].TagName) == normVer(spec.Tag) {
|
||||
return &list[i], nil, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("release %q not found (--list shows all)", spec.Tag)
|
||||
}
|
||||
|
||||
// The plain case is one request: the forge already knows its latest
|
||||
// non-draft, non-prerelease release.
|
||||
if !spec.Pre && !opt.list {
|
||||
body, err := c.apiGet(c.api+"/releases/latest", true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if body != nil {
|
||||
var rel Release
|
||||
if json.Unmarshal(body, &rel) == nil && rel.TagName != "" {
|
||||
return &rel, nil, nil
|
||||
}
|
||||
}
|
||||
verbose("no /releases/latest, falling back to the release list")
|
||||
}
|
||||
|
||||
list, err := c.releaseList()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, nil, fmt.Errorf("no releases found in %s/%s", c.owner, c.repo)
|
||||
}
|
||||
if opt.list {
|
||||
return nil, list, nil
|
||||
}
|
||||
for i := range list {
|
||||
if !list[i].Draft && (spec.Pre || !list[i].Prerelease) {
|
||||
return &list[i], nil, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("no suitable release found (try --pre)")
|
||||
}
|
||||
|
||||
func (c *forgeCtx) releaseList() ([]Release, error) {
|
||||
body, err := c.apiGet(c.releasesURL(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []Release
|
||||
if err := json.Unmarshal(body, &list); err != nil {
|
||||
return nil, fmt.Errorf("release list from %s is not an array: %w", c.releasesURL(), err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func normVer(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" && (v[0] == 'v' || v[0] == 'V') {
|
||||
v = v[1:]
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var userAgent = "upd/" + version + " (go)"
|
||||
|
||||
var (
|
||||
client *http.Client
|
||||
clientMode caMode = -1 // forces a build on first use
|
||||
)
|
||||
|
||||
// One client per verification mode. The timeout covers connect, TLS and the
|
||||
// wait for the response header - not the body, because a download may
|
||||
// legitimately take longer than that.
|
||||
func httpClient() (*http.Client, error) {
|
||||
if client != nil && clientMode == caCurrent {
|
||||
return client, nil
|
||||
}
|
||||
cfg, err := tlsConfig(caCurrent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := time.Duration(opt.timeout) * time.Second
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: to, KeepAlive: 30 * time.Second}).DialContext,
|
||||
TLSClientConfig: cfg,
|
||||
TLSHandshakeTimeout: to,
|
||||
ResponseHeaderTimeout: to,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
clientMode = caCurrent
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type response struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte // empty when the body went to dst
|
||||
}
|
||||
|
||||
// A writer that can be rewound, so a retry does not append to a half-written
|
||||
// file. *os.File satisfies it.
|
||||
type resettable interface {
|
||||
io.Writer
|
||||
Truncate(int64) error
|
||||
Seek(int64, int) (int64, error)
|
||||
}
|
||||
|
||||
// httpGet retries what curl's --retry covers: connection failures and server
|
||||
// side errors. A 4xx is an answer, not a hiccup, and is returned as is.
|
||||
func httpGet(url string, headers map[string]string, dst io.Writer) (*response, error) {
|
||||
const attempts = 3
|
||||
var lastErr error
|
||||
retryNow := false // set when the next attempt changes something itself
|
||||
|
||||
for i := range attempts {
|
||||
if i > 0 {
|
||||
if r, ok := dst.(resettable); ok {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Truncate(0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !retryNow {
|
||||
time.Sleep(time.Duration(i) * time.Second)
|
||||
verbose("retrying (%d/%d): %s", i, attempts-1, url)
|
||||
}
|
||||
}
|
||||
retryNow = false
|
||||
|
||||
// A CA bundle that cannot be read is a configuration error, not a
|
||||
// network one - repeating it would not help.
|
||||
if _, err := httpClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpTry(url, headers, dst)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// A store that does not know the issuer is not a hiccup, but it is
|
||||
// the one certificate failure a fresh root list can fix - so try
|
||||
// the bundled one straight away, and say so.
|
||||
if isUnknownAuthority(err) && caCurrent == caSystem && bundledPool() != nil {
|
||||
caCurrent = caBundled
|
||||
retryNow = true
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Note: the system CA store does not know this issuer, using the bundled CA list.\n"+
|
||||
" Update the ca-certificates package to make this permanent.\n")
|
||||
continue
|
||||
}
|
||||
if isPermanent(err) {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.status >= 500 && i < attempts-1 {
|
||||
lastErr = fmt.Errorf("HTTP %d: %s", resp.status, url)
|
||||
continue
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func httpTry(url string, headers map[string]string, dst io.Writer) (*response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
for _, k := range sortedKeys(headers) {
|
||||
req.Header.Set(k, headers[k])
|
||||
}
|
||||
|
||||
c, err := httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, transportError(err, url)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out := &response{status: resp.StatusCode, header: resp.Header}
|
||||
if dst != nil && resp.StatusCode == http.StatusOK {
|
||||
if _, err := io.Copy(dst, resp.Body); err != nil {
|
||||
return nil, fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// Everything that is not a download: API answers and the error bodies both
|
||||
// forges explain themselves in. A release list with 50 entries runs into
|
||||
// megabytes, so the cap is only there to bound a runaway response - and it
|
||||
// says so instead of handing on half a document.
|
||||
const maxBody = 32 << 20
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxBody {
|
||||
return nil, fmt.Errorf("response from %s is larger than %s", url, humanSize(maxBody))
|
||||
}
|
||||
out.body = body
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The Go TLS stack speaks whatever the server does, so the old "your OpenSSL
|
||||
// is too old" class of failure is gone - but DNS, proxies, clocks and private
|
||||
// CAs are still there. Name the one that hit.
|
||||
func transportError(err error, url string) error {
|
||||
var (
|
||||
dns *net.DNSError
|
||||
hostname x509.HostnameError
|
||||
invalid x509.CertificateInvalidError
|
||||
)
|
||||
msg := ""
|
||||
switch {
|
||||
case errors.As(err, &dns):
|
||||
msg = "the host could not be resolved - check DNS and $https_proxy"
|
||||
case isUnknownAuthority(err):
|
||||
msg = "the issuer is unknown even to the bundled CA list - for a private CA " +
|
||||
"pass --cacert <file>, or --insecure to skip verification"
|
||||
case errors.As(err, &invalid):
|
||||
msg = "the certificate is outside its validity period - check the system clock"
|
||||
case errors.As(err, &hostname):
|
||||
msg = "the certificate does not match the host name"
|
||||
case errors.Is(err, os.ErrDeadlineExceeded) || strings.Contains(err.Error(), "timeout"):
|
||||
msg = "timed out - raise --timeout"
|
||||
case strings.Contains(err.Error(), "connection refused"):
|
||||
msg = "connection refused - check the port and any firewall"
|
||||
}
|
||||
if msg == "" {
|
||||
return fmt.Errorf("request failed: %s\n %w", url, err)
|
||||
}
|
||||
return fmt.Errorf("request failed: %s\n %w\n %s", url, err, msg)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// API requests with an ETag cache
|
||||
// ==============================================================================
|
||||
|
||||
type cacheEntry struct {
|
||||
URL string `json:"url"`
|
||||
ETag string `json:"etag"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func cacheDir() string {
|
||||
base := os.Getenv("XDG_CACHE_HOME")
|
||||
if base == "" {
|
||||
base = filepath.Join(homeDir(), ".cache")
|
||||
}
|
||||
return filepath.Join(base, "upd")
|
||||
}
|
||||
|
||||
func cacheFile(url string) string {
|
||||
sum := sha256.Sum256([]byte(url))
|
||||
return filepath.Join(cacheDir(), hex.EncodeToString(sum[:])[:16]+".json")
|
||||
}
|
||||
|
||||
// apiGet fetches a JSON endpoint, revalidating a cached copy via ETag.
|
||||
// soft turns a 404 into (nil, nil) instead of an error.
|
||||
func (c *forgeCtx) apiGet(url string, soft bool) (json.RawMessage, error) {
|
||||
hdr := c.authHeaders()
|
||||
hdr["Accept"] = "application/json"
|
||||
if c.forge == "github" {
|
||||
hdr["Accept"] = "application/vnd.github+json"
|
||||
hdr["X-GitHub-Api-Version"] = "2022-11-28"
|
||||
}
|
||||
|
||||
var cached cacheEntry
|
||||
cf := cacheFile(url)
|
||||
if err := readJSON(cf, &cached); err == nil && cached.ETag != "" {
|
||||
hdr["If-None-Match"] = cached.ETag
|
||||
}
|
||||
|
||||
resp, err := httpGet(url, hdr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.status == http.StatusNotModified && cached.Body != "" {
|
||||
verbose("304 not modified, using cached %s", url)
|
||||
return validJSON([]byte(cached.Body), url, c)
|
||||
}
|
||||
if soft && resp.status == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.status == http.StatusForbidden || resp.status == http.StatusTooManyRequests {
|
||||
if left := resp.header.Get("X-RateLimit-Remaining"); left == "0" {
|
||||
return nil, fmt.Errorf("%s rate limit reached (remaining: %s).\n"+
|
||||
" Set a token via --token or $GITHUB_TOKEN", c.forge, left)
|
||||
}
|
||||
}
|
||||
// Both forges answer 404 for a repository the caller may not see, so a
|
||||
// repository that is missing and one that is merely private look alike.
|
||||
if resp.status == http.StatusUnauthorized || resp.status == http.StatusForbidden ||
|
||||
resp.status == http.StatusNotFound {
|
||||
hint := "a private repository needs --token or $UPD_TOKEN"
|
||||
if c.token != "" {
|
||||
hint = "the token does not grant access to this repository"
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d%s: %s\n %s", resp.status, apiMessage(resp.body), url, hint)
|
||||
}
|
||||
if resp.status != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.status, url)
|
||||
}
|
||||
|
||||
data, err := validJSON(resp.body, url, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if etag := resp.header.Get("ETag"); etag != "" {
|
||||
if err := os.MkdirAll(cacheDir(), 0o755); err == nil {
|
||||
writeJSON(cf, cacheEntry{URL: url, ETag: etag, Body: string(resp.body)})
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validJSON(body []byte, url string, c *forgeCtx) (json.RawMessage, error) {
|
||||
if !json.Valid(body) {
|
||||
return nil, fmt.Errorf("response from %s is not JSON (is this really a %s instance?)", url, c.forge)
|
||||
}
|
||||
if msg := apiMessage(body); msg != "" {
|
||||
return nil, fmt.Errorf("%s error:%s", c.forge, msg)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Both forges explain themselves in a JSON "message" field.
|
||||
func apiMessage(body []byte) string {
|
||||
var m struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &m); err != nil || m.Message == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + m.Message + ")"
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Downloads
|
||||
// ==============================================================================
|
||||
|
||||
func downloadTo(path, url string, headers map[string]string, size int64) error {
|
||||
fh, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
var dst io.Writer = fh
|
||||
if bar := newProgress(size); bar != nil {
|
||||
defer bar.finish()
|
||||
dst = &progressWriter{file: fh, bar: bar}
|
||||
}
|
||||
|
||||
resp, err := httpGet(url, headers, dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.status != http.StatusOK {
|
||||
return fmt.Errorf("download failed (HTTP %d%s): %s", resp.status, apiMessage(resp.body), url)
|
||||
}
|
||||
return fh.Sync()
|
||||
}
|
||||
|
||||
// progressWriter keeps the file a resettable writer for the retry path.
|
||||
type progressWriter struct {
|
||||
file *os.File
|
||||
bar *progress
|
||||
}
|
||||
|
||||
func (w *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := w.file.Write(p)
|
||||
w.bar.add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *progressWriter) Truncate(n int64) error { w.bar.reset(); return w.file.Truncate(n) }
|
||||
func (w *progressWriter) Seek(off int64, whence int) (int64, error) {
|
||||
return w.file.Seek(off, whence)
|
||||
}
|
||||
|
||||
type progress struct {
|
||||
total, got int64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newProgress(total int64) *progress {
|
||||
if opt.quiet || !isTerminal(os.Stdout) {
|
||||
return nil
|
||||
}
|
||||
return &progress{total: total}
|
||||
}
|
||||
|
||||
func (p *progress) reset() { p.got = 0 }
|
||||
|
||||
func (p *progress) add(n int64) {
|
||||
p.got += n
|
||||
if time.Since(p.last) < 100*time.Millisecond {
|
||||
return
|
||||
}
|
||||
p.last = time.Now()
|
||||
p.draw()
|
||||
}
|
||||
|
||||
func (p *progress) draw() {
|
||||
const width = 40
|
||||
if p.total <= 0 {
|
||||
fmt.Printf("\r %s", humanSize(p.got))
|
||||
return
|
||||
}
|
||||
pct := float64(p.got) / float64(p.total)
|
||||
if pct > 1 {
|
||||
pct = 1
|
||||
}
|
||||
filled := int(pct * width)
|
||||
fmt.Printf("\r [%s%s] %5.1f%% %s",
|
||||
strings.Repeat("#", filled), strings.Repeat(" ", width-filled),
|
||||
pct*100, humanSize(p.total))
|
||||
}
|
||||
|
||||
func (p *progress) finish() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.draw()
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
func isTerminal(f *os.File) bool {
|
||||
st, err := f.Stat()
|
||||
return err == nil && st.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
+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))
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// upd - download the matching binary from the latest Gitea/GitHub release.
|
||||
//
|
||||
// upd https://git.micw.org/mike/dns
|
||||
// upd https://github.com/sxyazi/yazi --install ~/bin --name yazi,ya
|
||||
// upd --all # everything listed in the config file
|
||||
// upd --all --check # exit 10 if any update is available
|
||||
//
|
||||
// A port of the Perl original. The one visible difference is the transport:
|
||||
// TLS comes from the Go runtime, so there is no curl, wget or IO::Socket::SSL
|
||||
// in the picture and no old system library to trip over. State and cache files
|
||||
// keep the Perl layout, so both versions can share ~/.local/state/upd.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// version is a var, not a const, so build.sh can put the built number in via
|
||||
// -ldflags "-X main.version=...". The value here only ever shows up in a bare
|
||||
// `go build`; the version actually built is the one in version.txt.
|
||||
var version = "2.0.0"
|
||||
|
||||
// Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check only).
|
||||
const (
|
||||
exOK = 0
|
||||
exUsage = 1
|
||||
exError = 2
|
||||
exOutdated = 10
|
||||
)
|
||||
|
||||
// Options that belong to a single repository. Anything else is global and
|
||||
// applies to every entry of an --all run.
|
||||
var specKeys = []string{
|
||||
"install", "name", "tag", "asset", "pattern", "os", "arch",
|
||||
"forge", "token", "version-flag", "pre",
|
||||
}
|
||||
|
||||
type options struct {
|
||||
repo string
|
||||
tag string
|
||||
install string
|
||||
name string
|
||||
asset string
|
||||
pattern string
|
||||
os string
|
||||
arch string
|
||||
token string
|
||||
forge string
|
||||
versionFlag string
|
||||
timeout int
|
||||
config string
|
||||
cacert string
|
||||
insecure bool
|
||||
all bool
|
||||
check bool
|
||||
pre bool
|
||||
force bool
|
||||
list bool
|
||||
dryRun bool
|
||||
quiet bool
|
||||
verbose bool
|
||||
help bool
|
||||
// upd on itself, see selfupdate.go
|
||||
showVersion bool
|
||||
checkUpdate bool
|
||||
doUpdate bool
|
||||
updateRefresh bool
|
||||
}
|
||||
|
||||
var (
|
||||
opt options
|
||||
// Which options the user actually typed. Perl could ask "is it defined?";
|
||||
// here the zero value of a flag is indistinguishable from an unset one,
|
||||
// and only typed options may override a config file entry.
|
||||
given = map[string]bool{}
|
||||
)
|
||||
|
||||
func main() {
|
||||
code := run()
|
||||
cleanupTemps()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func run() int {
|
||||
fs := flag.NewFlagSet("upd", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard) // errors are reported together with the usage text
|
||||
|
||||
fs.StringVar(&opt.repo, "repo", "", "")
|
||||
fs.StringVar(&opt.tag, "tag", "", "")
|
||||
for _, n := range []string{"install", "dest", "i"} {
|
||||
fs.StringVar(&opt.install, n, "", "")
|
||||
}
|
||||
fs.StringVar(&opt.name, "name", "", "")
|
||||
fs.StringVar(&opt.asset, "asset", "", "")
|
||||
fs.StringVar(&opt.pattern, "pattern", "", "")
|
||||
fs.StringVar(&opt.os, "os", "", "")
|
||||
fs.StringVar(&opt.arch, "arch", "", "")
|
||||
fs.StringVar(&opt.token, "token", "", "")
|
||||
fs.StringVar(&opt.forge, "forge", "", "")
|
||||
fs.StringVar(&opt.versionFlag, "version-flag", "--version", "")
|
||||
fs.IntVar(&opt.timeout, "timeout", 30, "")
|
||||
fs.StringVar(&opt.config, "config", "", "")
|
||||
fs.StringVar(&opt.cacert, "cacert", "", "")
|
||||
fs.BoolVar(&opt.insecure, "insecure", false, "")
|
||||
fs.BoolVar(&opt.insecure, "k", false, "")
|
||||
fs.BoolVar(&opt.all, "all", false, "")
|
||||
fs.BoolVar(&opt.check, "check", false, "")
|
||||
fs.BoolVar(&opt.pre, "pre", false, "")
|
||||
fs.BoolVar(&opt.force, "force", false, "")
|
||||
fs.BoolVar(&opt.list, "list", false, "")
|
||||
fs.BoolVar(&opt.dryRun, "dry-run", false, "")
|
||||
fs.BoolVar(&opt.quiet, "quiet", false, "")
|
||||
fs.BoolVar(&opt.verbose, "verbose", false, "")
|
||||
fs.BoolVar(&opt.help, "help", false, "")
|
||||
fs.BoolVar(&opt.help, "h", false, "")
|
||||
fs.BoolVar(&opt.showVersion, "version", false, "")
|
||||
fs.BoolVar(&opt.checkUpdate, "check-update", false, "")
|
||||
fs.BoolVar(&opt.doUpdate, "update", false, "")
|
||||
fs.BoolVar(&opt.updateRefresh, updateRefreshFlag, false, "")
|
||||
|
||||
if err := fs.Parse(permute(fs, os.Args[1:])); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
usage(os.Stdout)
|
||||
return exOK
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
|
||||
usage(os.Stderr)
|
||||
return exUsage
|
||||
}
|
||||
if opt.help {
|
||||
usage(os.Stdout)
|
||||
return exOK
|
||||
}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
switch f.Name { // the aliases answer to the name the rest of the code knows
|
||||
case "dest", "i":
|
||||
given["install"] = true
|
||||
default:
|
||||
given[f.Name] = true
|
||||
}
|
||||
})
|
||||
|
||||
// An explicit choice about certificates switches off the automatic
|
||||
// fallback: from here on it is the user's decision, not a guess.
|
||||
switch {
|
||||
case opt.insecure:
|
||||
caCurrent = caNone
|
||||
fmt.Fprintf(os.Stderr, "Warning: --insecure, the server certificate is not verified.\n")
|
||||
case caCertPath() != "":
|
||||
caCurrent = caFile
|
||||
}
|
||||
verbose("certificates: %v", caCurrent)
|
||||
|
||||
// Leftovers from an interrupted install must not linger next to the binary.
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
go func() {
|
||||
<-sig
|
||||
cleanupTemps()
|
||||
os.Exit(130)
|
||||
}()
|
||||
|
||||
// upd on itself: each of these is a whole run of its own, nothing follows.
|
||||
switch {
|
||||
case opt.updateRefresh: // the background look, deliberately not in the help
|
||||
selfUpdate.refresh()
|
||||
return exOK
|
||||
case opt.showVersion:
|
||||
fmt.Printf("upd %s\n", version)
|
||||
return exOK
|
||||
case opt.checkUpdate:
|
||||
if err := selfUpdate.check(os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return exError
|
||||
}
|
||||
return exOK
|
||||
case opt.doUpdate:
|
||||
if err := selfUpdate.install(os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return exError
|
||||
}
|
||||
return exOK
|
||||
}
|
||||
|
||||
specs, err := buildSpecs(fs.Args())
|
||||
if err != nil {
|
||||
if errors.Is(err, errUsage) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
|
||||
usage(os.Stderr)
|
||||
return exUsage
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return exError
|
||||
}
|
||||
|
||||
var results []result
|
||||
for _, spec := range specs {
|
||||
res, err := updateOne(spec)
|
||||
if err != nil {
|
||||
res = result{status: "error", label: spec.label(), msg: oneLine(err.Error())}
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", res.label, err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
|
||||
if len(specs) > 1 && !opt.list {
|
||||
summary(results)
|
||||
}
|
||||
// Costs nothing: the hint comes from the note in the cache, and the asking
|
||||
// happens once a day at most, in the background.
|
||||
if hint := selfUpdate.daily(); hint != "" && !opt.quiet {
|
||||
fmt.Fprintln(os.Stderr, hint)
|
||||
}
|
||||
return worstCode(results)
|
||||
}
|
||||
|
||||
// Go's flag package stops at the first operand, but "upd URL -i ." has to keep
|
||||
// working, so operands are moved behind the flags before parsing. A flag that
|
||||
// takes a value swallows the argument after it, unless it is a boolean or
|
||||
// already carries "=value".
|
||||
func permute(fs *flag.FlagSet, args []string) []string {
|
||||
var flags, operands []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
operands = append(operands, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
operands = append(operands, a)
|
||||
continue
|
||||
}
|
||||
name := strings.TrimLeft(a, "-")
|
||||
inline := strings.Contains(name, "=")
|
||||
if inline {
|
||||
name, _, _ = strings.Cut(name, "=")
|
||||
}
|
||||
flags = append(flags, a)
|
||||
if inline || isBoolFlag(fs, name) || i+1 >= len(args) {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
return append(flags, operands...)
|
||||
}
|
||||
|
||||
func isBoolFlag(fs *flag.FlagSet, name string) bool {
|
||||
f := fs.Lookup(name)
|
||||
if f == nil {
|
||||
return false // unknown: let flag.Parse produce the error
|
||||
}
|
||||
b, ok := f.Value.(interface{ IsBoolFlag() bool })
|
||||
return ok && b.IsBoolFlag()
|
||||
}
|
||||
|
||||
var errUsage = errors.New("missing repository URL")
|
||||
|
||||
func buildSpecs(args []string) ([]*Spec, error) {
|
||||
if opt.all {
|
||||
path := opt.config
|
||||
if path == "" {
|
||||
path = configPath()
|
||||
}
|
||||
specs, err := readConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
return nil, fmt.Errorf("no entries in %s", path)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
url := opt.repo
|
||||
if url == "" && len(args) > 0 {
|
||||
url = args[0]
|
||||
}
|
||||
if url == "" {
|
||||
url = os.Getenv("UPD_REPO")
|
||||
}
|
||||
if url == "" {
|
||||
return nil, errUsage
|
||||
}
|
||||
s := &Spec{Repo: url}
|
||||
s.applyGlobals()
|
||||
return []*Spec{s}, nil
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Reporting
|
||||
// ==============================================================================
|
||||
|
||||
type result struct {
|
||||
status string // ok, current, updated, outdated, listed, dry, error
|
||||
label string
|
||||
msg string
|
||||
}
|
||||
|
||||
func summary(res []result) {
|
||||
rows := res
|
||||
if opt.quiet {
|
||||
// --quiet is meant for cron: report only what needs attention.
|
||||
rows = nil
|
||||
for _, r := range res {
|
||||
switch r.status {
|
||||
case "error", "outdated", "updated":
|
||||
rows = append(rows, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
w := 0
|
||||
for _, r := range rows {
|
||||
if len(r.label) > w {
|
||||
w = len(r.label)
|
||||
}
|
||||
}
|
||||
fmt.Print("\nSummary:\n")
|
||||
for _, r := range rows {
|
||||
fmt.Printf(" %-9s %-*s %s\n", r.status, w, r.label, r.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func worstCode(res []result) int {
|
||||
for _, r := range res {
|
||||
if r.status == "error" {
|
||||
return exError
|
||||
}
|
||||
}
|
||||
for _, r := range res {
|
||||
if r.status == "outdated" {
|
||||
return exOutdated
|
||||
}
|
||||
}
|
||||
return exOK
|
||||
}
|
||||
|
||||
func info(format string, a ...any) {
|
||||
if !opt.quiet {
|
||||
fmt.Printf(format+"\n", a...)
|
||||
}
|
||||
}
|
||||
|
||||
func verbose(format string, a ...any) {
|
||||
if opt.verbose {
|
||||
fmt.Printf(" "+format+"\n", a...)
|
||||
}
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintf(w, `upd %s - download the matching binary from the latest Gitea/GitHub release
|
||||
|
||||
upd [OPTIONS] REPO-URL
|
||||
upd --all [OPTIONS]
|
||||
|
||||
REPO-URL e.g. https://git.micw.org/mike/dns
|
||||
https://github.com/sxyazi/yazi
|
||||
(or use --repo / $UPD_REPO)
|
||||
|
||||
Options:
|
||||
--install PATH, -i target directory (created if missing), e.g. ~/bin.
|
||||
A path whose last segment is one of the binary names,
|
||||
or an existing file, means that exact file.
|
||||
Also $UPD_INSTALL. Default: the directory of a
|
||||
same-named binary in $PATH, else ~/.local/bin
|
||||
--name A[,B...] binary name(s) to take out of one asset, e.g.
|
||||
"yazi,ya" installs both. Use "SRC:DST" to install
|
||||
under a different name (default: repository name)
|
||||
--all update every entry of the config file
|
||||
--config PATH config file (default: %s)
|
||||
--check only report whether an update is available
|
||||
--tag VERSION install a specific release ("v1.2.3" or "1.2.3")
|
||||
--pre consider prereleases as well
|
||||
--asset NAME exact asset name instead of auto-detection
|
||||
--pattern REGEX select the asset by regex
|
||||
--os OS darwin|linux|windows|freebsd (default: detected)
|
||||
--arch ARCH amd64|arm64|386|arm (default: detected)
|
||||
--forge NAME github|gitea (default: github for github.com hosts,
|
||||
gitea otherwise; needed for GitHub Enterprise)
|
||||
--token TOKEN API token for private repositories. Also read from
|
||||
$UPD_TOKEN, then $GITHUB_TOKEN/$GH_TOKEN (GitHub)
|
||||
or $GITEA_TOKEN (Gitea)
|
||||
--version-flag F flag to query the version of a binary installed
|
||||
without upd (default: --version)
|
||||
--cacert FILE verify against this CA bundle instead of the system
|
||||
store (also $UPD_CACERT). Without it, upd uses the
|
||||
system store and falls back to its own built-in
|
||||
Mozilla CA list if that store does not know the issuer
|
||||
--insecure, -k do not verify the server certificate at all
|
||||
--force install even if it is already up to date
|
||||
--list show releases and their assets
|
||||
--dry-run show what would happen, write nothing
|
||||
--verbose show asset scoring and cache decisions
|
||||
--quiet print errors only
|
||||
--timeout SEC network timeout (default: 30)
|
||||
--help this help
|
||||
--version print the version of upd itself
|
||||
--check-update look for a newer release of upd itself
|
||||
--update download and install the newest release of upd itself
|
||||
|
||||
Config file (one line per tool, "#" comments):
|
||||
https://git.micw.org/mike/dns install=~/bin
|
||||
https://github.com/sxyazi/yazi install=~/bin name=yazi,ya
|
||||
Keys: %s
|
||||
|
||||
State is kept in %s
|
||||
Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check)
|
||||
|
||||
upd looks for a new release of itself once a day, in the background, and says
|
||||
so on stderr. UPD_NO_UPDATE_CHECK=1 turns that off.
|
||||
`, version, configPath(), strings.Join(specKeys, ", "), stateDir())
|
||||
}
|
||||
|
||||
func listReleases(rels []Release) {
|
||||
for _, r := range rels {
|
||||
pre := ""
|
||||
if r.Prerelease {
|
||||
pre = " [prerelease]"
|
||||
}
|
||||
published := r.PublishedAt
|
||||
if len(published) > 10 {
|
||||
published = published[:10]
|
||||
}
|
||||
fmt.Printf("%-12s %s%s\n", r.TagName, published, pre)
|
||||
for _, a := range r.Assets {
|
||||
fmt.Printf(" %-32s %s\n", a.Name, humanSize(a.Size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys keeps map iteration out of the output.
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func homeDir() string {
|
||||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// expandTilde handles the one form a config file realistically contains.
|
||||
func expandTilde(p string) string {
|
||||
if p == "~" {
|
||||
return homeDir()
|
||||
}
|
||||
if strings.HasPrefix(p, "~/") {
|
||||
return filepath.Join(homeDir(), p[2:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
// selfupdate.go - upd updating itself.
|
||||
//
|
||||
// The mechanism is the one dx uses: --check-update looks, --update installs,
|
||||
// and an ordinary run consults a note in the cache once a day and mentions a
|
||||
// newer release on stderr - never asking in the foreground, never asking when
|
||||
// nobody is watching. The work itself goes through upd's own machinery: the
|
||||
// same forge layer, the same retries and CA fallback, the same checksum check
|
||||
// and the same atomic install, so updating upd behaves like updating any other
|
||||
// tool it installs.
|
||||
//
|
||||
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||
// is the bare number (2.0.6, a leading "v" is allowed), holding one asset
|
||||
// "upd-<goos>-<goarch>" each - that is, exactly the files from ./bin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ Configuration
|
||||
|
||||
var selfUpdate = selfUpdater{
|
||||
repo: "https://git.micw.org/mike/upd",
|
||||
name: "upd",
|
||||
current: version, // from main.go, set by -ldflags
|
||||
every: 24 * time.Hour,
|
||||
quietEnv: "UPD_NO_UPDATE_CHECK",
|
||||
}
|
||||
|
||||
type selfUpdater struct {
|
||||
repo string // repo URL as in the browser: https://host/owner/repo
|
||||
name string // base name of the assets, "-<goos>-<goarch>" is added
|
||||
current string // the running version
|
||||
every time.Duration // how often to look on its own; 0 turns that off
|
||||
quietEnv string // this environment variable set: keep quiet as well
|
||||
}
|
||||
|
||||
// updateRefreshFlag is the option upd calls itself with, in the background. It
|
||||
// is deliberately absent from the help.
|
||||
const updateRefreshFlag = "update-refresh"
|
||||
|
||||
// ------------------------------------------------------------ Looking by itself
|
||||
|
||||
// daily is the hook for the ordinary run. It costs nothing: in the foreground
|
||||
// the network is never touched. What comes back is the line pointing at a new
|
||||
// version - or "", when there is nothing to say. Should the note be older than
|
||||
// `every`, daily starts a background run on the side, whose answer the next
|
||||
// call will find waiting.
|
||||
func (u selfUpdater) daily() string {
|
||||
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !isTerminal(os.Stderr) {
|
||||
return ""
|
||||
}
|
||||
st := u.loadState() // no file: the zero value, hence due at once
|
||||
|
||||
if time.Since(st.Checked) >= u.every {
|
||||
// The timestamp moves on before the asking, not after: otherwise two
|
||||
// simultaneous runs start two queries, and a server that is not in the
|
||||
// mood would get a new one on every call. If the note does not stay
|
||||
// put, nothing is asked either - else an unwritable cache directory
|
||||
// would mean one process per call.
|
||||
st.Checked = time.Now()
|
||||
if u.saveState(st) == nil {
|
||||
u.spawnRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
if st.Latest == "" || compareVer(st.Latest, u.current) <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Note: %s %s is available, run '%s --update'.", u.name, st.Latest, u.name)
|
||||
}
|
||||
|
||||
// refresh is the background run: ask, write it down, stay quiet. The writing
|
||||
// down is done by latest; if the query fails, the old state remains.
|
||||
func (u selfUpdater) refresh() {
|
||||
_, _, _ = u.latest()
|
||||
}
|
||||
|
||||
// spawnRefresh calls upd once more, only to ask, and does not wait. Without a
|
||||
// Wait the child is adopted by init when this process ends - it thus outlives
|
||||
// the call, and the call's output stays untouched by it.
|
||||
func (u selfUpdater) spawnRefresh() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(exe, "--"+updateRefreshFlag)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
|
||||
if cmd.Start() == nil {
|
||||
cmd.Process.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Note
|
||||
|
||||
// updateState is what is left between two calls: when the last question was
|
||||
// asked and what came of it.
|
||||
type updateState struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// The note lives in the cache directory next to the ETag cache, not in the
|
||||
// state directory: if it gets lost, the only cost is asking once too early.
|
||||
// The name cannot collide with a cache file, those are hex digests.
|
||||
func (u selfUpdater) statePath() string {
|
||||
return filepath.Join(cacheDir(), "selfupdate.json")
|
||||
}
|
||||
|
||||
func (u selfUpdater) loadState() updateState {
|
||||
var st updateState
|
||||
readJSON(u.statePath(), &st) // a missing or broken file counts as none
|
||||
return st
|
||||
}
|
||||
|
||||
func (u selfUpdater) saveState(st updateState) error {
|
||||
if err := os.MkdirAll(cacheDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(u.statePath(), st)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ The work
|
||||
|
||||
// check only looks and touches nothing.
|
||||
func (u selfUpdater) check(w io.Writer) error {
|
||||
_, rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compareVer(rel.TagName, u.current) <= 0 {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s is available, running %s\n", u.name, rel.TagName, u.current)
|
||||
if rel.HTMLURL != "" {
|
||||
fmt.Fprintf(w, " %s\n", rel.HTMLURL)
|
||||
}
|
||||
fmt.Fprintf(w, " run '%s --update' to install it\n", u.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// install fetches the newest release and replaces the running file with it.
|
||||
func (u selfUpdater) install(w io.Writer) error {
|
||||
c, rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compareVer(rel.TagName, u.current) <= 0 && !opt.force {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current)
|
||||
return nil
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("%s-%s-%s", u.name, runtime.GOOS, runtime.GOARCH)
|
||||
var src *Asset
|
||||
for i := range rel.Assets {
|
||||
if rel.Assets[i].Name == want {
|
||||
src = &rel.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
var have []string
|
||||
for _, a := range rel.Assets {
|
||||
have = append(have, a.Name)
|
||||
}
|
||||
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(have, ", "))
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate the running binary: %w", err)
|
||||
}
|
||||
// An installed upd may be a symlink into ./bin. What should be replaced is
|
||||
// the file behind it, not the link.
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
// Ask before the download, not after it: a missing write permission ought
|
||||
// to show up before a few megabytes have gone down the wire.
|
||||
if dir := filepath.Dir(exe); !writable(dir) {
|
||||
return fmt.Errorf("no write permission in %s (use sudo, or install upd elsewhere)", dir)
|
||||
}
|
||||
|
||||
if opt.dryRun {
|
||||
fmt.Fprintf(w, "[dry-run] would install %s %s to %s\n", want, rel.TagName, exe)
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpdir, err := os.MkdirTemp("", "upd-self-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
dl := filepath.Join(tmpdir, want)
|
||||
dlURL, dlHdr := c.assetURL(src)
|
||||
hdr := c.authHeaders()
|
||||
for k, v := range dlHdr {
|
||||
hdr[k] = v
|
||||
}
|
||||
fmt.Fprintf(w, "Downloading %s %s (%s) ...\n", want, rel.TagName, humanSize(src.Size))
|
||||
if err := downloadTo(dl, dlURL, hdr, src.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
if st, err := os.Stat(dl); err == nil && src.Size > 0 && st.Size() < src.Size {
|
||||
return fmt.Errorf("incomplete download: %d of %d bytes", st.Size(), src.Size)
|
||||
}
|
||||
if err := verifyDownload(c, dl, src, rel.Assets, tmpdir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dl, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.probe(dl, rel.TagName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := installAtomic(dl, exe); err != nil {
|
||||
return err
|
||||
}
|
||||
stripQuarantine(exe)
|
||||
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||
|
||||
fmt.Fprintf(w, "%s %s -> %s, at %s\n", u.name, u.current, rel.TagName, exe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probe runs the freshly fetched binary once. That catches a file that is
|
||||
// truncated, built for the wrong platform, or not executable in the first
|
||||
// place, before it replaces the running one. The version flag is the built-in
|
||||
// one, not spec.versionFlag(): what upd answers to is not the user's to
|
||||
// configure here.
|
||||
func (u selfUpdater) probe(path, tag string) error {
|
||||
got := installedVersion(path, &Spec{VersionFlag: "--version"})
|
||||
if got == "" {
|
||||
return fmt.Errorf("the downloaded binary does not run, %s left alone", u.name)
|
||||
}
|
||||
if got != normVer(tag) {
|
||||
return fmt.Errorf("the downloaded binary reports %s, expected %s", got, normVer(tag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// latest asks the forge for the newest release, through the same code path an
|
||||
// ordinary run uses, and notes down what came back.
|
||||
func (u selfUpdater) latest() (*forgeCtx, *Release, error) {
|
||||
spec := &Spec{Repo: u.repo}
|
||||
c, err := newForgeCtx(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rel, _, err := c.fetchRelease(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if rel == nil { // --list on the command line: fetchRelease returns the list
|
||||
return nil, nil, fmt.Errorf("--list cannot be combined with --update")
|
||||
}
|
||||
|
||||
// Every question that succeeds fills the note - no matter whether it came
|
||||
// from --update, from --check-update or from the background run.
|
||||
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||
return c, rel, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Numbers
|
||||
|
||||
// compareVer compares two versions component by component, numerically, so
|
||||
// that 2.0.10 lands behind 2.0.9 and not in front of it. A leading "v" does not
|
||||
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
|
||||
// makes the version older, not newer (2.0.6-rc1 < 2.0.6). The result is the one
|
||||
// of strings.Compare: -1, 0, 1.
|
||||
func compareVer(a, b string) int {
|
||||
as := strings.Split(normVer(a), ".")
|
||||
bs := strings.Split(normVer(b), ".")
|
||||
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
x, y := "0", "0"
|
||||
if i < len(as) {
|
||||
x = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
y = bs[i]
|
||||
}
|
||||
if c := compareVerPart(x, y); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareVerPart(a, b string) int {
|
||||
na, ra := splitNum(a)
|
||||
nb, rb := splitNum(b)
|
||||
switch {
|
||||
case na != nb:
|
||||
if na < nb {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case ra == rb:
|
||||
return 0
|
||||
case ra == "": // 2.0.6 is finished, 2.0.6-rc1 is not yet
|
||||
return 1
|
||||
case rb == "":
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(ra, rb)
|
||||
}
|
||||
|
||||
// splitNum separates "10-rc1" into 10 and "-rc1".
|
||||
func splitNum(s string) (int, string) {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
n, _ := strconv.Atoi(s[:i])
|
||||
return n, s[i:]
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spec is one repository plus the options that belong to it - either a command
|
||||
// line invocation or one line of the config file.
|
||||
type Spec struct {
|
||||
Repo string
|
||||
Install string
|
||||
Name string
|
||||
Tag string
|
||||
Asset string
|
||||
Pattern string
|
||||
OS string
|
||||
Arch string
|
||||
Forge string
|
||||
Token string
|
||||
VersionFlag string
|
||||
Pre bool
|
||||
}
|
||||
|
||||
func (s *Spec) set(key, val string) error {
|
||||
switch key {
|
||||
case "install":
|
||||
s.Install = val
|
||||
case "name":
|
||||
s.Name = val
|
||||
case "tag":
|
||||
s.Tag = val
|
||||
case "asset":
|
||||
s.Asset = val
|
||||
case "pattern":
|
||||
s.Pattern = val
|
||||
case "os":
|
||||
s.OS = val
|
||||
case "arch":
|
||||
s.Arch = val
|
||||
case "forge":
|
||||
s.Forge = val
|
||||
case "token":
|
||||
s.Token = val
|
||||
case "version-flag":
|
||||
s.VersionFlag = val
|
||||
case "pre":
|
||||
s.Pre = val != "" && val != "0"
|
||||
default:
|
||||
return fmt.Errorf("unknown key %q (allowed: %s)", key, strings.Join(specKeys, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options typed on the command line win over the config file. Unlike the Perl
|
||||
// version this looks at what was actually given, so a "version-flag=" in the
|
||||
// config file is no longer overwritten by its own default.
|
||||
func (s *Spec) applyGlobals() {
|
||||
for _, k := range specKeys {
|
||||
if !given[k] {
|
||||
continue
|
||||
}
|
||||
var v string
|
||||
switch k {
|
||||
case "install":
|
||||
v = opt.install
|
||||
case "name":
|
||||
v = opt.name
|
||||
case "tag":
|
||||
v = opt.tag
|
||||
case "asset":
|
||||
v = opt.asset
|
||||
case "pattern":
|
||||
v = opt.pattern
|
||||
case "os":
|
||||
v = opt.os
|
||||
case "arch":
|
||||
v = opt.arch
|
||||
case "forge":
|
||||
v = opt.forge
|
||||
case "token":
|
||||
v = opt.token
|
||||
case "version-flag":
|
||||
v = opt.versionFlag
|
||||
case "pre":
|
||||
if !opt.pre {
|
||||
continue // --pre only ever switches on
|
||||
}
|
||||
v = "1"
|
||||
}
|
||||
s.set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Spec) versionFlag() string {
|
||||
if s.VersionFlag != "" {
|
||||
return s.VersionFlag
|
||||
}
|
||||
return opt.versionFlag
|
||||
}
|
||||
|
||||
func (s *Spec) label() string {
|
||||
if s.Name != "" {
|
||||
return parseNames(s.Name, "?")[0].out
|
||||
}
|
||||
r := strings.TrimRight(s.Repo, "/")
|
||||
if r == "" {
|
||||
return "?"
|
||||
}
|
||||
return filepath.Base(r)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Config file (--all)
|
||||
// ==============================================================================
|
||||
|
||||
func configPath() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
base = filepath.Join(homeDir(), ".config")
|
||||
}
|
||||
return filepath.Join(base, "upd", "tools")
|
||||
}
|
||||
|
||||
// One entry per line: <repo-url> [key=value ...] [pre]
|
||||
func readConfig(path string) ([]*Spec, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read config %s: %w\n"+
|
||||
"Format: one line per tool, e.g.\n"+
|
||||
" https://github.com/sxyazi/yazi install=~/bin name=yazi,ya", path, err)
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
var specs []*Spec
|
||||
sc := bufio.NewScanner(fh)
|
||||
for ln := 1; sc.Scan(); ln++ {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
tokens := splitTokens(line)
|
||||
s := &Spec{Repo: tokens[0]}
|
||||
for _, t := range tokens[1:] {
|
||||
key, val, ok := strings.Cut(t, "=")
|
||||
if !ok {
|
||||
val = "1" // a bare "pre"
|
||||
}
|
||||
if err := s.set(strings.ToLower(key), val); err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", path, ln, err)
|
||||
}
|
||||
}
|
||||
s.applyGlobals()
|
||||
specs = append(specs, s)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, fmt.Errorf("cannot read config %s: %w", path, err)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// Whitespace separated, with double quotes around values that contain spaces.
|
||||
func splitTokens(line string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
quoted, escaped, started := false, false, false
|
||||
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case escaped:
|
||||
cur.WriteRune(r)
|
||||
escaped = false
|
||||
case r == '\\' && quoted:
|
||||
escaped = true
|
||||
case r == '"':
|
||||
quoted = !quoted
|
||||
started = true
|
||||
case (r == ' ' || r == '\t') && !quoted:
|
||||
if started {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
started = false
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
if started {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Binary names
|
||||
// ==============================================================================
|
||||
|
||||
// name holds what to look for inside the asset (src) and what to write to disk
|
||||
// (out): "yazi,ya" are two binaries, "yazi:yazi-nightly" renames one.
|
||||
type name struct{ src, out string }
|
||||
|
||||
func parseNames(list, fallback string) []name {
|
||||
var names []name
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
src, out, ok := strings.Cut(part, ":")
|
||||
if !ok || out == "" {
|
||||
out = src
|
||||
}
|
||||
names = append(names, name{src: src, out: out})
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = append(names, name{src: fallback, out: fallback})
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The Mozilla CA list as extracted by the curl project, refreshed with
|
||||
//
|
||||
// curl -o ca-bundle.pem https://curl.se/ca/cacert.pem
|
||||
//
|
||||
// It is not used unless the system store fails: on a machine old enough that
|
||||
// its ca-certificates package predates Let's Encrypt's ISRG roots, every
|
||||
// https:// forge is unreachable otherwise, and updating that package is often
|
||||
// no longer possible there.
|
||||
//
|
||||
//go:embed ca-bundle.pem
|
||||
var caBundle []byte
|
||||
|
||||
// How certificates are verified. Starts at the system store and falls back one
|
||||
// step when that store turns out not to know the issuer.
|
||||
type caMode int
|
||||
|
||||
const (
|
||||
caSystem caMode = iota
|
||||
caBundled
|
||||
caFile
|
||||
caNone
|
||||
)
|
||||
|
||||
func (m caMode) String() string {
|
||||
switch m {
|
||||
case caBundled:
|
||||
return "bundled CA list"
|
||||
case caFile:
|
||||
return "--cacert " + caCertPath()
|
||||
case caNone:
|
||||
return "no verification"
|
||||
}
|
||||
return "system CA store"
|
||||
}
|
||||
|
||||
// caCurrent is the mode every request uses; the fallback in httpGet moves it
|
||||
// forward at most once per run.
|
||||
var caCurrent = caSystem
|
||||
|
||||
func bundledPool() *x509.CertPool {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caBundle) {
|
||||
return nil
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func filePool(path string) (*x509.CertPool, error) {
|
||||
pem, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read --cacert %s: %w", path, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("no certificates found in %s", path)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// tlsConfig builds the config for the mode currently in force. The system
|
||||
// store stays the default: it is the one the administrator controls.
|
||||
func tlsConfig(mode caMode) (*tls.Config, error) {
|
||||
switch mode {
|
||||
case caBundled:
|
||||
pool := bundledPool()
|
||||
if pool == nil {
|
||||
return nil, errors.New("the bundled CA list could not be parsed")
|
||||
}
|
||||
return &tls.Config{RootCAs: pool}, nil
|
||||
case caFile:
|
||||
pool, err := filePool(caCertPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{RootCAs: pool}, nil
|
||||
case caNone:
|
||||
return &tls.Config{InsecureSkipVerify: true}, nil
|
||||
}
|
||||
return &tls.Config{}, nil
|
||||
}
|
||||
|
||||
func caCertPath() string {
|
||||
if opt.cacert != "" {
|
||||
return expandTilde(opt.cacert)
|
||||
}
|
||||
return expandTilde(os.Getenv("UPD_CACERT"))
|
||||
}
|
||||
|
||||
// A certificate the local store cannot chain up to a root it knows. This is
|
||||
// the failure the bundled list exists for; no other x509 problem (expired,
|
||||
// wrong host name) would be fixed by more roots.
|
||||
func isUnknownAuthority(err error) bool {
|
||||
var unknown x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknown) {
|
||||
return true
|
||||
}
|
||||
// Some paths only carry the verifier's verdict as text.
|
||||
return strings.Contains(err.Error(), "certificate signed by unknown authority") ||
|
||||
strings.Contains(err.Error(), "x509: failed to load system roots")
|
||||
}
|
||||
|
||||
// Errors that will still be errors on the next attempt: retrying a rejected
|
||||
// certificate, an unresolvable host or a handshake the two sides cannot agree
|
||||
// on only makes the output longer.
|
||||
func isPermanent(err error) bool {
|
||||
var (
|
||||
hostname x509.HostnameError
|
||||
invalid x509.CertificateInvalidError
|
||||
verify *tls.CertificateVerificationError
|
||||
record tls.RecordHeaderError
|
||||
dns *net.DNSError
|
||||
)
|
||||
switch {
|
||||
case isUnknownAuthority(err),
|
||||
errors.As(err, &hostname),
|
||||
errors.As(err, &invalid),
|
||||
errors.As(err, &verify),
|
||||
errors.As(err, &record):
|
||||
return true
|
||||
case errors.As(err, &dns):
|
||||
return !dns.IsTemporary
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "tls: ") || strings.Contains(msg, "x509: ")
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetTransport clears the memoised client so a test can change the
|
||||
// verification mode.
|
||||
func resetTransport(t *testing.T, mode caMode) {
|
||||
t.Helper()
|
||||
client, clientMode, caCurrent = nil, -1, mode
|
||||
opt.timeout = 10
|
||||
t.Cleanup(func() {
|
||||
client, clientMode, caCurrent = nil, -1, caSystem
|
||||
opt.cacert, opt.insecure = "", false
|
||||
})
|
||||
}
|
||||
|
||||
// tlsServer returns a server with a certificate no public CA has signed, plus
|
||||
// that certificate as PEM - the stand-in for a root the system does not know.
|
||||
func tlsServer(t *testing.T) (*httptest.Server, []byte) {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"tag_name":"v1.2.3"}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
|
||||
return srv, certPEM
|
||||
}
|
||||
|
||||
func TestSystemStoreRejectsUnknownIssuer(t *testing.T) {
|
||||
srv, _ := tlsServer(t)
|
||||
resetTransport(t, caSystem)
|
||||
|
||||
// Nothing to fall back to: the bundled list does not know it either.
|
||||
old := caBundle
|
||||
caBundle = nil
|
||||
t.Cleanup(func() { caBundle = old })
|
||||
|
||||
_, err := httpGet(srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the system store to reject this certificate")
|
||||
}
|
||||
if !isUnknownAuthority(err) {
|
||||
t.Errorf("not recognised as an unknown authority: %v", err)
|
||||
}
|
||||
if !isPermanent(err) {
|
||||
t.Error("a rejected certificate must not be retried")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cacert") {
|
||||
t.Errorf("error should point at the way out, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The failure from the old box: the system store predates the issuer's root.
|
||||
// upd must notice, switch to its own list and carry on.
|
||||
func TestFallbackToBundledCAs(t *testing.T) {
|
||||
srv, certPEM := tlsServer(t)
|
||||
resetTransport(t, caSystem)
|
||||
|
||||
old := caBundle
|
||||
caBundle = certPEM
|
||||
t.Cleanup(func() { caBundle = old })
|
||||
|
||||
resp, err := httpGet(srv.URL, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback did not happen: %v", err)
|
||||
}
|
||||
if resp.status != http.StatusOK {
|
||||
t.Errorf("status %d", resp.status)
|
||||
}
|
||||
if caCurrent != caBundled {
|
||||
t.Errorf("mode is %v, want the bundled list", caCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacertFile(t *testing.T) {
|
||||
srv, certPEM := tlsServer(t)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ca.pem")
|
||||
if err := os.WriteFile(path, certPEM, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetTransport(t, caFile)
|
||||
opt.cacert = path
|
||||
if _, err := httpGet(srv.URL, nil, nil); err != nil {
|
||||
t.Fatalf("--cacert did not verify: %v", err)
|
||||
}
|
||||
|
||||
// A bundle without the right root must still fail, and say which file.
|
||||
resetTransport(t, caFile)
|
||||
opt.cacert = filepath.Join(t.TempDir(), "empty.pem")
|
||||
os.WriteFile(opt.cacert, []byte("not a certificate\n"), 0o644)
|
||||
if _, err := httpGet(srv.URL, nil, nil); err == nil {
|
||||
t.Error("expected an error for a bundle without certificates")
|
||||
} else if !strings.Contains(err.Error(), "no certificates found") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsecureSkipsVerification(t *testing.T) {
|
||||
srv, _ := tlsServer(t)
|
||||
resetTransport(t, caNone)
|
||||
|
||||
if _, err := httpGet(srv.URL, nil, nil); err != nil {
|
||||
t.Fatalf("--insecure should have connected anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bundled list has to be a usable pool and carry the roots that the old
|
||||
// machines are missing - that is the whole point of embedding it.
|
||||
func TestBundledCAList(t *testing.T) {
|
||||
pool := bundledPool()
|
||||
if pool == nil {
|
||||
t.Fatal("the embedded CA bundle does not parse")
|
||||
}
|
||||
|
||||
var subjects []string
|
||||
for block, rest := pem.Decode(caBundle); block != nil; block, rest = pem.Decode(rest) {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("unparsable certificate in the bundle: %v", err)
|
||||
}
|
||||
subjects = append(subjects, cert.Subject.CommonName)
|
||||
}
|
||||
if len(subjects) < 100 {
|
||||
t.Errorf("only %d roots in the bundle, expected the full Mozilla list", len(subjects))
|
||||
}
|
||||
for _, want := range []string{"ISRG Root X1", "ISRG Root X2"} {
|
||||
found := false
|
||||
for _, s := range subjects {
|
||||
if s == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s missing - Let's Encrypt hosts would still fail", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentClassification(t *testing.T) {
|
||||
// A DNS failure for a name that cannot exist is permanent, a plain
|
||||
// connection refused is not: the second one is worth another attempt.
|
||||
resetTransport(t, caSystem)
|
||||
if _, err := httpGet("https://no-such-host.invalid/x", nil, nil); err == nil {
|
||||
t.Fatal("expected a DNS failure")
|
||||
} else if !isPermanent(err) {
|
||||
t.Errorf("an unresolvable host should not be retried: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// updateOne runs one repository end to end.
|
||||
func updateOne(spec *Spec) (result, error) {
|
||||
c, err := newForgeCtx(spec)
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
|
||||
// Without --name the repository name is both what to look for and what to
|
||||
// write; parseNames falls back to it on its own.
|
||||
names := parseNames(spec.Name, c.repo)
|
||||
goos, goarch := detectPlatform(spec)
|
||||
|
||||
info("Repo: %s/%s/%s [%s]", c.base, c.owner, c.repo, c.forge)
|
||||
info("Platform: %s/%s", goos, goarch)
|
||||
|
||||
rel, list, err := c.fetchRelease(spec)
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
if opt.list {
|
||||
if rel != nil {
|
||||
list = []Release{*rel}
|
||||
}
|
||||
listReleases(list)
|
||||
return result{status: "listed", label: names[0].out}, nil
|
||||
}
|
||||
|
||||
pre := ""
|
||||
if rel.Prerelease {
|
||||
pre = " [prerelease]"
|
||||
}
|
||||
info("Release: %s (%s)%s", rel.TagName, rel.PublishedAt, pre)
|
||||
|
||||
if len(rel.Assets) == 0 {
|
||||
return result{}, fmt.Errorf("release %s has no assets", rel.TagName)
|
||||
}
|
||||
|
||||
asset, err := pickAsset(rel.Assets, names[0].src, goos, goarch, spec)
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
if asset == nil {
|
||||
var have []string
|
||||
for _, a := range rel.Assets {
|
||||
have = append(have, a.Name)
|
||||
}
|
||||
return result{}, fmt.Errorf("no asset for %s/%s in release %s.\nAvailable:\n %s\n"+
|
||||
"Select one explicitly with --asset <name> or --pattern <regex>",
|
||||
goos, goarch, rel.TagName, strings.Join(have, "\n "))
|
||||
}
|
||||
info("Asset: %s (%s)", asset.Name, humanSize(asset.Size))
|
||||
|
||||
targets, err := resolveTargets(spec, names, goos)
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
var dests []string
|
||||
for _, t := range targets {
|
||||
dests = append(dests, t.dest)
|
||||
}
|
||||
info("Target: %s", strings.Join(dests, ", "))
|
||||
|
||||
stale := targets[:0:0]
|
||||
for _, t := range targets {
|
||||
if !targetCurrent(t, rel.TagName, asset, spec) {
|
||||
stale = append(stale, t)
|
||||
}
|
||||
}
|
||||
if len(stale) == 0 && !opt.force {
|
||||
info("Already up to date (%s) - nothing to do.", rel.TagName)
|
||||
return result{status: "current", label: names[0].out, msg: rel.TagName}, nil
|
||||
}
|
||||
if opt.force {
|
||||
stale = targets
|
||||
}
|
||||
|
||||
if opt.check {
|
||||
var have []string
|
||||
for _, t := range stale {
|
||||
have = append(have, installedTag(t))
|
||||
}
|
||||
info("Update available: %s (installed: %s)", rel.TagName, strings.Join(have, ", "))
|
||||
return result{status: "outdated", label: names[0].out,
|
||||
msg: fmt.Sprintf("%s (have: %s)", rel.TagName, strings.Join(have, ", "))}, nil
|
||||
}
|
||||
|
||||
dlURL, dlHdr := c.assetURL(asset)
|
||||
if opt.dryRun {
|
||||
fmt.Printf("[dry-run] would download: %s\n", dlURL)
|
||||
for _, t := range stale {
|
||||
fmt.Printf("[dry-run] would install to: %s\n", t.dest)
|
||||
}
|
||||
return result{status: "dry", label: names[0].out, msg: rel.TagName}, nil
|
||||
}
|
||||
|
||||
// --- download -------------------------------------------------------------
|
||||
|
||||
tmpdir, err := os.MkdirTemp("", "upd-")
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
dl := filepath.Join(tmpdir, asset.Name)
|
||||
info("Downloading %s ...", dlURL)
|
||||
|
||||
hdr := c.authHeaders()
|
||||
for k, v := range dlHdr {
|
||||
hdr[k] = v
|
||||
}
|
||||
if err := downloadTo(dl, dlURL, hdr, asset.Size); err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
if st, err := os.Stat(dl); err == nil && asset.Size > 0 && st.Size() < asset.Size {
|
||||
return result{}, fmt.Errorf("incomplete download: %d of %d bytes", st.Size(), asset.Size)
|
||||
}
|
||||
|
||||
if err := verifyDownload(c, dl, asset, rel.Assets, tmpdir); err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
|
||||
// --- extract and install --------------------------------------------------
|
||||
|
||||
root, err := extractIfArchive(dl, tmpdir)
|
||||
if err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
for _, t := range stale {
|
||||
src := dl
|
||||
if root != "" {
|
||||
src = findInTree(root, t.name)
|
||||
if src == "" {
|
||||
return result{}, fmt.Errorf("binary %q not found inside %s", t.name, asset.Name)
|
||||
}
|
||||
}
|
||||
if !looksExecutable(src) {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s does not look like an executable.\n", t.name)
|
||||
}
|
||||
|
||||
if err := installAtomic(src, t.dest); err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
stripQuarantine(t.dest)
|
||||
if err := writeState(t, c, rel.TagName, asset); err != nil {
|
||||
return result{}, err
|
||||
}
|
||||
info("Installed: %s (%s)", t.dest, rel.TagName)
|
||||
if v := installedVersion(t.dest, spec); v != "" {
|
||||
info("Version: %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, t := range stale {
|
||||
dir := filepath.Dir(t.dest)
|
||||
if !seen[dir] && !inPath(dir) {
|
||||
fmt.Fprintf(os.Stderr, "Note: %s is not in $PATH.\n", dir)
|
||||
}
|
||||
seen[dir] = true
|
||||
}
|
||||
return result{status: "updated", label: names[0].out, msg: rel.TagName}, nil
|
||||
}
|
||||
|
||||
func detectPlatform(spec *Spec) (string, string) {
|
||||
goos, goarch := runtime.GOOS, runtime.GOARCH
|
||||
if spec.OS != "" {
|
||||
goos = spec.OS
|
||||
}
|
||||
if spec.Arch != "" {
|
||||
goarch = spec.Arch
|
||||
}
|
||||
return goos, goarch
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// Checksums
|
||||
// ==============================================================================
|
||||
|
||||
var (
|
||||
sha256Re = regexp.MustCompile(`(?i)^sha256:([0-9a-f]{64})$`)
|
||||
sumLineRe = regexp.MustCompile(`^([0-9a-fA-F]{64})(\s+\*?(\S+))?`)
|
||||
sumsFileRe = regexp.MustCompile(`(?i)^(sha256sums?(\.txt)?|checksums?\.txt)$`)
|
||||
)
|
||||
|
||||
func verifyDownload(c *forgeCtx, file string, asset *Asset, assets []Asset, tmpdir string) error {
|
||||
var want, src string
|
||||
|
||||
if m := sha256Re.FindStringSubmatch(asset.Digest); m != nil {
|
||||
want, src = strings.ToLower(m[1]), "asset digest"
|
||||
} else {
|
||||
var sum *Asset
|
||||
for i := range assets {
|
||||
if assets[i].Name == asset.Name+".sha256" {
|
||||
sum = &assets[i]
|
||||
}
|
||||
}
|
||||
if sum == nil {
|
||||
for i := range assets {
|
||||
if sumsFileRe.MatchString(assets[i].Name) {
|
||||
sum = &assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if sum == nil {
|
||||
return nil // nothing published to check against
|
||||
}
|
||||
|
||||
sf := filepath.Join(tmpdir, "sums")
|
||||
u, h := c.assetURL(sum)
|
||||
hdr := c.authHeaders()
|
||||
for k, v := range h {
|
||||
hdr[k] = v
|
||||
}
|
||||
if err := downloadTo(sf, u, hdr, 0); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Checksum file could not be downloaded, skipping verification.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fh, err := os.Open(sf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
sc := bufio.NewScanner(fh)
|
||||
for sc.Scan() {
|
||||
// "<hash> <name>", or just "<hash>" in a single-file .sha256
|
||||
m := sumLineRe.FindStringSubmatch(sc.Text())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m[3] != "" && filepath.Base(m[3]) != asset.Name {
|
||||
continue
|
||||
}
|
||||
want, src = strings.ToLower(m[1]), sum.Name
|
||||
break
|
||||
}
|
||||
if want == "" {
|
||||
// An unreadable or unrelated checksum file is not a failed
|
||||
// verification, only a missing one.
|
||||
if err := sc.Err(); err != nil {
|
||||
verbose("could not read %s: %v", sum.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
got := fileSHA256(file)
|
||||
if got != want {
|
||||
return fmt.Errorf("SHA256 mismatch (%s)!\n expected: %s\n got: %s", src, want, got)
|
||||
}
|
||||
info("SHA256: ok (%s)", src)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func readJSON(path string, v any) error {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// writeJSON replaces the file atomically: a half-written state file would look
|
||||
// like a corrupt install on the next run.
|
||||
func writeJSON(path string, v any) error {
|
||||
body, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := fmt.Sprintf("%s.%d", path, os.Getpid())
|
||||
addTemp(tmp)
|
||||
defer dropTemp(tmp)
|
||||
|
||||
if err := os.WriteFile(tmp, append(body, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("cannot write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("cannot update %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func humanSize(n int64) string {
|
||||
switch {
|
||||
case n >= 1024*1024:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
|
||||
case n >= 1024:
|
||||
return fmt.Sprintf("%.1f kB", float64(n)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
2.0.3
|
||||
Reference in New Issue
Block a user