mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
718 lines
20 KiB
Go
718 lines
20 KiB
Go
package updater
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/minio/selfupdate"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
// httpClient is a shared HTTP client used for release checks and downloads.
|
|
// The Timeout value applies to the entire HTTP request: dialing, TLS
|
|
// handshake, redirects, and reading the response body. It is NOT only
|
|
// a connection (dial) timeout. To control lower-level timeouts (dial,
|
|
// TLS handshake, response header wait), supply a custom Transport with
|
|
// an appropriately configured net.Dialer.
|
|
var httpClient = &http.Client{Timeout: 2 * time.Minute}
|
|
|
|
func getWithRetry(rawURL string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return utils.DoRequestWithRetry(httpClient, req)
|
|
}
|
|
|
|
// DownloadAndExtractRelease downloads a release archive (or uses a direct
|
|
// asset URL) and extracts it to a temporary directory. It returns the
|
|
// extraction directory on success. If releaseURL is empty, the latest
|
|
// release of the current project is used. platform/arch can be used to
|
|
// select the correct asset (e.g. "linux", "amd64").
|
|
func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
|
|
assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Download asset to temp file. Use the asset URL extension so
|
|
// extractArchive can detect the archive format (zip/tar.gz/tar).
|
|
tmpPattern := "picoclaw-release-*"
|
|
if u, perr := url.Parse(assetURL); perr == nil {
|
|
base := filepath.Base(u.Path)
|
|
lbase := strings.ToLower(base)
|
|
switch {
|
|
case strings.HasSuffix(lbase, ".zip"):
|
|
tmpPattern += ".zip"
|
|
case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
|
|
tmpPattern += ".tar.gz"
|
|
case strings.HasSuffix(lbase, ".tar"):
|
|
tmpPattern += ".tar"
|
|
default:
|
|
tmpPattern += ".archive"
|
|
}
|
|
} else {
|
|
tmpPattern += ".archive"
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", tmpPattern)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer tmpFile.Close()
|
|
|
|
resp, err := getWithRetry(assetURL)
|
|
if err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
os.Remove(tmpPath)
|
|
return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Stream download while computing SHA256 to avoid a second download.
|
|
// Also show a simple progress line to stderr so users see activity.
|
|
h := sha256.New()
|
|
pw := &progressWriter{total: resp.ContentLength}
|
|
mw := io.MultiWriter(tmpFile, h, pw)
|
|
if _, err = io.Copy(mw, resp.Body); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
// ensure final progress line ends with newline
|
|
pw.Finish()
|
|
|
|
// verify checksum if available
|
|
if checksum != "" {
|
|
got := hex.EncodeToString(h.Sum(nil))
|
|
if !strings.EqualFold(got, checksum) {
|
|
_ = os.Remove(tmpPath)
|
|
return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
|
|
}
|
|
}
|
|
|
|
// Extract
|
|
destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
|
|
if err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
|
|
if err := extractArchive(tmpPath, destDir); err != nil {
|
|
os.Remove(tmpPath)
|
|
os.RemoveAll(destDir)
|
|
return "", err
|
|
}
|
|
|
|
// cleanup archive file; keep extracted contents
|
|
_ = os.Remove(tmpPath)
|
|
return destDir, nil
|
|
}
|
|
|
|
// UpdateSelfFromRelease downloads the release matching the given parameters,
|
|
// extracts it and applies the binary named programName to update the
|
|
// currently running executable using minio/selfupdate.
|
|
// If releaseURL is empty, the latest release is used. If platform or arch
|
|
// is empty, runtime values are used.
|
|
func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
|
|
if platform == "" {
|
|
platform = runtime.GOOS
|
|
}
|
|
if arch == "" {
|
|
arch = runtime.GOARCH
|
|
}
|
|
|
|
dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
binPath, err := findBinaryInDir(dir, programName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// ensure executable bit on non-windows
|
|
if runtime.GOOS != "windows" {
|
|
_ = os.Chmod(binPath, 0o755)
|
|
}
|
|
|
|
f, err := os.Open(binPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
// Backup current executable so we can roll back if needed.
|
|
var opts selfupdate.Options
|
|
if exePath, err := os.Executable(); err == nil {
|
|
opts.OldSavePath = exePath + ".old"
|
|
}
|
|
|
|
if err := selfupdate.Apply(f, opts); err != nil {
|
|
return fmt.Errorf("apply update: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateSelf updates the running executable by fetching the latest release
|
|
// and applying the binary matching programName.
|
|
func UpdateSelf(programName string) error {
|
|
// By default, select the latest stable release when no explicit
|
|
// release URL is provided. Use --nightly or a custom URL to override.
|
|
return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
|
|
}
|
|
|
|
// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
|
|
// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
|
|
func GetReleaseAPIURL(owner string) string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
|
|
}
|
|
|
|
// GetProdReleaseAPIURL returns the production release API URL (upstream).
|
|
func GetProdReleaseAPIURL() string {
|
|
return GetReleaseAPIURL("sipeed")
|
|
}
|
|
|
|
// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
|
|
// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
|
|
func GetReleaseTagAPIURL(owner, tag string) string {
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
|
|
}
|
|
|
|
// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
|
|
func GetNightlyReleaseAPIURL() string {
|
|
return GetReleaseTagAPIURL("sipeed", "nightly")
|
|
}
|
|
|
|
// findAssetURL resolves the appropriate asset URL for the given release
|
|
// selector. It accepts direct archive URLs as well as GitHub release URLs
|
|
// or empty (latest release for the project).
|
|
func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
|
|
// returns (assetURL, sha256ChecksumHex, error)
|
|
if looksLikeDirectAssetURL(releaseURL) {
|
|
return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
|
|
}
|
|
|
|
apiURL := buildReleaseAPIURL(releaseURL)
|
|
if apiURL == "" {
|
|
// If caller provided an empty releaseURL, default to the
|
|
// production latest release API URL (stable release).
|
|
apiURL = GetProdReleaseAPIURL()
|
|
}
|
|
|
|
resp, err := getWithRetry(apiURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var data struct {
|
|
TagName string `json:"tag_name"`
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
Digest string `json:"digest"`
|
|
} `json:"assets"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// Selection order: platform -> arch -> extension.
|
|
platformLower := strings.ToLower(platform)
|
|
archLower := strings.ToLower(arch)
|
|
|
|
isZip := func(name string) bool {
|
|
return strings.HasSuffix(name, ".zip")
|
|
}
|
|
isTarGz := func(name string) bool {
|
|
return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
|
|
}
|
|
isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
|
|
|
|
// collect indices of assets that contain platform (if provided)
|
|
var platformIdx []int
|
|
for i, a := range data.Assets {
|
|
n := strings.ToLower(a.Name)
|
|
if platform == "" || strings.Contains(n, platformLower) {
|
|
platformIdx = append(platformIdx, i)
|
|
}
|
|
}
|
|
|
|
pickBest := func(idxs []int) (string, int, bool) {
|
|
if len(idxs) == 0 {
|
|
return "", -1, false
|
|
}
|
|
// prefer arch matches within idxs; if arch was specified but
|
|
// no arch match exists among idxs, treat as no candidate.
|
|
var archIdx []int
|
|
if arch != "" {
|
|
aliases := archAliases(archLower)
|
|
for _, i := range idxs {
|
|
n := strings.ToLower(data.Assets[i].Name)
|
|
for _, ali := range aliases {
|
|
if strings.Contains(n, ali) {
|
|
archIdx = append(archIdx, i)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(archIdx) == 0 {
|
|
return "", -1, false
|
|
}
|
|
}
|
|
candidates := archIdx
|
|
if len(candidates) == 0 {
|
|
candidates = idxs
|
|
}
|
|
|
|
// extension preference
|
|
if platformLower == "windows" {
|
|
// prefer .zip only
|
|
for _, i := range candidates {
|
|
if isZip(strings.ToLower(data.Assets[i].Name)) {
|
|
return data.Assets[i].BrowserDownloadURL, i, true
|
|
}
|
|
}
|
|
// if no zip found, fallthrough to first candidate
|
|
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
|
|
}
|
|
|
|
// non-windows: prefer tar.gz/tgz, then tar, then zip
|
|
for _, i := range candidates {
|
|
if isTarGz(strings.ToLower(data.Assets[i].Name)) {
|
|
return data.Assets[i].BrowserDownloadURL, i, true
|
|
}
|
|
}
|
|
for _, i := range candidates {
|
|
if isTar(strings.ToLower(data.Assets[i].Name)) {
|
|
return data.Assets[i].BrowserDownloadURL, i, true
|
|
}
|
|
}
|
|
for _, i := range candidates {
|
|
if isZip(strings.ToLower(data.Assets[i].Name)) {
|
|
return data.Assets[i].BrowserDownloadURL, i, true
|
|
}
|
|
}
|
|
// fallback to first candidate
|
|
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
|
|
}
|
|
|
|
// Try platform matches first
|
|
if url, idx, ok := pickBest(platformIdx); ok {
|
|
// attempt to find checksum: prefer asset digest from API if present
|
|
if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
|
|
dLower := strings.ToLower(d)
|
|
if strings.HasPrefix(dLower, "sha256:") {
|
|
hexpart := strings.TrimPrefix(dLower, "sha256:")
|
|
return url, hexpart, nil
|
|
}
|
|
// If digest already looks like a 64-hex, return it
|
|
if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
|
|
return url, dLower, nil
|
|
}
|
|
}
|
|
// Look for checksum assets and verify by computing the asset's sha256.
|
|
for j, a := range data.Assets {
|
|
n := strings.ToLower(a.Name)
|
|
if strings.Contains(n, "sha256") ||
|
|
strings.Contains(n, "sha256sum") ||
|
|
strings.Contains(n, "checksums") ||
|
|
strings.HasSuffix(n, ".sha256") ||
|
|
strings.HasSuffix(n, ".sha256sum") {
|
|
resp2, err := getWithRetry(data.Assets[j].BrowserDownloadURL)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bs, err := io.ReadAll(resp2.Body)
|
|
resp2.Body.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if h, ok := findHashInChecksumContent(bs, url); ok {
|
|
return url, h, nil
|
|
}
|
|
}
|
|
}
|
|
// No checksum found for the selected platform asset -> error
|
|
return "", "", fmt.Errorf("no checksum found for asset %s", url)
|
|
}
|
|
|
|
// No platform match — require explicit platform+arch; fail fast.
|
|
return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
|
|
}
|
|
|
|
func looksLikeDirectAssetURL(u string) bool {
|
|
if u == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(u)
|
|
if strings.HasSuffix(lower, ".zip") ||
|
|
strings.HasSuffix(lower, ".tar.gz") ||
|
|
strings.HasSuffix(lower, ".tgz") ||
|
|
strings.HasSuffix(lower, ".tar") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "/releases/download/") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildReleaseAPIURL(releaseURL string) string {
|
|
if releaseURL == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(releaseURL, "api.github.com") {
|
|
return releaseURL
|
|
}
|
|
u, err := url.Parse(releaseURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if u.Host != "github.com" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
owner := parts[0]
|
|
repo := parts[1]
|
|
// if tag specified
|
|
if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
|
|
tag := parts[4]
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
|
|
}
|
|
// default to latest
|
|
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
|
}
|
|
|
|
// NOTE: helper functions to compute SHA256 from URL/path were removed
|
|
// after refactoring to stream the download and verify the checksum
|
|
// during the single download to avoid double-transfer.
|
|
|
|
// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
|
|
// checksum file content that corresponds to assetURL. It returns the
|
|
// found hash (lowercase) and true, or "", false if not found.
|
|
func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
|
|
s := strings.ToLower(string(bs))
|
|
var assetBase string
|
|
if u, err := url.Parse(assetURL); err == nil {
|
|
assetBase = strings.ToLower(filepath.Base(u.Path))
|
|
} else {
|
|
assetBase = strings.ToLower(filepath.Base(assetURL))
|
|
}
|
|
re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
|
|
// prefer a line containing the asset filename
|
|
for _, line := range strings.Split(s, "\n") {
|
|
if strings.Contains(line, assetBase) {
|
|
if m := re.FindString(line); m != "" {
|
|
return m, true
|
|
}
|
|
}
|
|
}
|
|
// fallback: if there's exactly one unique 64-hex value, return it
|
|
matches := re.FindAllString(s, -1)
|
|
uniq := map[string]struct{}{}
|
|
for _, m := range matches {
|
|
uniq[m] = struct{}{}
|
|
}
|
|
if len(uniq) == 1 {
|
|
for k := range uniq {
|
|
return k, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// progressWriter implements io.Writer and prints a simple progress
|
|
// line to stderr while bytes are written. It is intended to be used
|
|
// as one writer in an io.MultiWriter so we can stream-to-disk, compute
|
|
// the sha256, and update the progress display in a single pass.
|
|
type progressWriter struct {
|
|
total int64
|
|
written int64
|
|
last time.Time
|
|
}
|
|
|
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
|
n := len(p)
|
|
pw.written += int64(n)
|
|
now := time.Now()
|
|
if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
|
|
pw.print()
|
|
pw.last = now
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (pw *progressWriter) print() {
|
|
if pw.total > 0 {
|
|
pct := float64(pw.written) * 100.0 / float64(pw.total)
|
|
fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
|
|
}
|
|
}
|
|
|
|
func (pw *progressWriter) Finish() {
|
|
pw.print()
|
|
fmt.Fprintln(os.Stderr, "")
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
f := float64(n)
|
|
const (
|
|
KB = 1024.0
|
|
MB = KB * 1024.0
|
|
GB = MB * 1024.0
|
|
)
|
|
switch {
|
|
case f >= GB:
|
|
return fmt.Sprintf("%.2f GB", f/GB)
|
|
case f >= MB:
|
|
return fmt.Sprintf("%.2f MB", f/MB)
|
|
case f >= KB:
|
|
return fmt.Sprintf("%.2f KB", f/KB)
|
|
default:
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
}
|
|
|
|
// archAliases returns common name variants for an architecture string
|
|
// so we can match release asset names like "x86_64" vs Go's "amd64".
|
|
// archAliases returns name variants for an architecture string.
|
|
// If `arch` is empty or matches the local runtime.GOARCH, prefer the
|
|
// compile-time architecture aliases provided by archAliasesForLocal
|
|
// (implemented per-architecture via build tags). For other `arch`
|
|
// values we use a small synonyms map.
|
|
func archAliases(arch string) []string {
|
|
a := strings.ToLower(arch)
|
|
if syns, ok := archSynonyms[a]; ok {
|
|
return syns
|
|
}
|
|
return []string{a}
|
|
}
|
|
|
|
var archSynonyms = map[string][]string{
|
|
"amd64": {"amd64", "x86_64", "x64"},
|
|
"x86_64": {"amd64", "x86_64", "x64"},
|
|
"x64": {"amd64", "x86_64", "x64"},
|
|
"386": {"386", "x86"},
|
|
"x86": {"386", "x86"},
|
|
"arm64": {"arm64", "aarch64"},
|
|
"aarch64": {"arm64", "aarch64"},
|
|
"arm": {"arm"},
|
|
}
|
|
|
|
func extractArchive(archivePath, destDir string) error {
|
|
lower := strings.ToLower(archivePath)
|
|
if strings.HasSuffix(lower, ".zip") {
|
|
return extractZip(archivePath, destDir)
|
|
}
|
|
// treat .tar.gz and .tgz as gzip+tar
|
|
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
|
|
return extractTarGz(archivePath, destDir)
|
|
}
|
|
if strings.HasSuffix(lower, ".tar") {
|
|
return extractTar(archivePath, destDir)
|
|
}
|
|
// fallback: try tar.gz
|
|
return extractTarGz(archivePath, destDir)
|
|
}
|
|
|
|
func extractZip(archivePath, destDir string) error {
|
|
r, err := zip.OpenReader(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.Close()
|
|
destClean := filepath.Clean(destDir)
|
|
for _, f := range r.File {
|
|
target := filepath.Clean(filepath.Join(destClean, f.Name))
|
|
if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
|
|
return fmt.Errorf("path traversal detected: %s", f.Name)
|
|
}
|
|
if f.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
|
|
if err != nil {
|
|
rc.Close()
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, rc); err != nil {
|
|
rc.Close()
|
|
out.Close()
|
|
return err
|
|
}
|
|
rc.Close()
|
|
out.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractTarGz(archivePath, destDir string) error {
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
gzr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gzr.Close()
|
|
tr := tar.NewReader(gzr)
|
|
return extractTarFromReader(tr, destDir)
|
|
}
|
|
|
|
func extractTar(archivePath, destDir string) error {
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
tr := tar.NewReader(f)
|
|
return extractTarFromReader(tr, destDir)
|
|
}
|
|
|
|
// extractTarFromReader contains logic common to extracting entries from a
|
|
// tar.Reader and is used by both extractTarGz and extractTar to avoid
|
|
// duplicated code (golangci-lint: dupl).
|
|
func extractTarFromReader(tr *tar.Reader, destDir string) error {
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
|
|
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
|
|
target != filepath.Clean(destDir) {
|
|
return fmt.Errorf("path traversal detected: %s", hdr.Name)
|
|
}
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, tr); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
out.Close()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findBinaryInDir(dir, programName string) (string, error) {
|
|
wanted := []string{programName}
|
|
if runtime.GOOS == "windows" {
|
|
wanted = append([]string{programName + ".exe"}, wanted...)
|
|
} else {
|
|
// also accept programs with .exe in archives targeting windows
|
|
wanted = append(wanted, programName+".exe")
|
|
}
|
|
|
|
var found string
|
|
if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || found != "" {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
base := filepath.Base(p)
|
|
for _, w := range wanted {
|
|
if base == w {
|
|
found = p
|
|
return io.EOF // use EOF to stop walking early
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil && err != io.EOF {
|
|
return "", err
|
|
}
|
|
if found == "" {
|
|
return "", fmt.Errorf("binary %q not found in archive", programName)
|
|
}
|
|
return found, nil
|
|
}
|
|
|
|
// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
|
|
func NewUpdateCommand(binaryName string) *cobra.Command {
|
|
var urlStr, platform, arch string
|
|
cmd := &cobra.Command{
|
|
Use: "update",
|
|
Short: "Check and apply updates from GitHub releases",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if platform == "" {
|
|
platform = runtime.GOOS
|
|
}
|
|
if arch == "" {
|
|
arch = runtime.GOARCH
|
|
}
|
|
fmt.Printf("Current version: %s\n", config.FormatVersion())
|
|
if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Update applied; restart to use the new version.")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
|
|
cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
|
|
cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
|
|
return cmd
|
|
}
|