upgrade.go

  1package cli
  2
  3import (
  4	"archive/tar"
  5	"archive/zip"
  6	"compress/gzip"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"os"
 11	"path/filepath"
 12	"runtime"
 13	"strings"
 14	"time"
 15
 16	"github.com/floatpane/matcha/internal/httpclient"
 17)
 18
 19// Release describes a GitHub release and its assets.
 20type Release struct {
 21	TagName    string `json:"tag_name"`
 22	Prerelease bool   `json:"prerelease"`
 23	Assets     []struct {
 24		Name               string `json:"name"`
 25		BrowserDownloadURL string `json:"browser_download_url"`
 26	} `json:"assets"`
 27}
 28
 29const (
 30	goosDarwin  = "darwin"
 31	goosLinux   = "linux"
 32	goosWindows = "windows"
 33)
 34
 35const maxBinarySize = 512 * 1024 * 1024 // 512 MiB
 36
 37// copyLimited copies at most maxBinarySize bytes from src to dst. It is used to
 38// avoid decompression bomb attacks when extracting binaries from archives.
 39func copyLimited(dst io.Writer, src io.Reader) error {
 40	n, err := io.CopyN(dst, src, maxBinarySize+1)
 41	if err != nil && !errors.Is(err, io.EOF) {
 42		return err
 43	}
 44	if n > maxBinarySize {
 45		return fmt.Errorf("extracted binary exceeds maximum size of %d bytes", maxBinarySize)
 46	}
 47	return nil
 48}
 49
 50// FindAsset returns the name and download URL for a release asset matching the
 51// given OS and architecture.
 52func FindAsset(rel *Release, osName, arch string) (string, string, error) {
 53	for _, a := range rel.Assets {
 54		n := strings.ToLower(a.Name)
 55		if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
 56			return a.Name, a.BrowserDownloadURL, nil
 57		}
 58	}
 59	for _, a := range rel.Assets {
 60		n := strings.ToLower(a.Name)
 61		if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
 62			return a.Name, a.BrowserDownloadURL, nil
 63		}
 64	}
 65	return "", "", fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
 66}
 67
 68// UpgradeBinaryFromAsset downloads the named release asset, extracts the matcha
 69// binary, and replaces the running executable.
 70func UpgradeBinaryFromAsset(assetURL, assetName, tag, cmdName string) error {
 71	execPath, err := os.Executable()
 72	if err != nil {
 73		return fmt.Errorf("could not determine executable path: %w", err)
 74	}
 75	execDir := filepath.Dir(execPath)
 76
 77	if err := ensureWritable(execDir, cmdName); err != nil {
 78		return err
 79	}
 80
 81	fmt.Printf("Found release asset: %s\n", assetName)
 82	fmt.Println("Downloading...")
 83
 84	client := httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
 85	respAsset, err := client.Get(assetURL) //nolint:noctx
 86	if err != nil {
 87		return fmt.Errorf("download failed: %w", err)
 88	}
 89	defer respAsset.Body.Close() //nolint:errcheck
 90
 91	// Create a temp file for the download.
 92	tmpDir, err := os.MkdirTemp("", "matcha-update-*")
 93	if err != nil {
 94		return fmt.Errorf("could not create temp dir: %w", err)
 95	}
 96	defer os.RemoveAll(tmpDir) //nolint:errcheck
 97
 98	assetPath := filepath.Join(tmpDir, assetName)
 99	outFile, err := os.Create(assetPath)
100	if err != nil {
101		return fmt.Errorf("could not create temp file: %w", err)
102	}
103	_, err = io.Copy(outFile, respAsset.Body)
104	if err != nil {
105		_ = outFile.Close()
106		return fmt.Errorf("could not write asset to disk: %w", err)
107	}
108	if err := outFile.Close(); err != nil {
109		return fmt.Errorf("could not finalize asset file: %w", err)
110	}
111
112	// Extract binary from archive.
113	binPath, err := extractBinaryFromArchive(assetPath, assetName, tmpDir)
114	if err != nil {
115		return err
116	}
117
118	// Replace the executable.
119	if err := replaceExecutable(binPath, execDir); err != nil {
120		return err
121	}
122
123	fmt.Println("Successfully updated matcha to", tag)
124	return nil
125}
126
127func ensureWritable(execDir, cmdName string) error {
128	testFile := filepath.Join(execDir, ".matcha_update_test")
129	if _, err := os.Create(testFile); err != nil {
130		if runtime.GOOS != goosWindows && os.Geteuid() != 0 {
131			fmt.Println("\n⚠️  Permission denied: Cannot write to installation directory.")
132			fmt.Printf("   Try running with sudo: sudo %s\n", cmdName)
133			fmt.Println("   Or reinstall using your package manager.")
134			return fmt.Errorf("permission denied: cannot write to %s", execDir)
135		}
136		return fmt.Errorf("cannot write to installation directory %s: %w", execDir, err)
137	}
138	_ = os.Remove(testFile)
139	return nil
140}
141
142// extractBinaryFromArchive extracts the matcha binary from a tar.gz, tgz, or zip archive.
143func extractBinaryFromArchive(assetPath, assetName, tmpDir string) (string, error) {
144	binaryName := "matcha"
145	if runtime.GOOS == goosWindows {
146		binaryName = "matcha.exe"
147	}
148
149	var binPath string
150	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic
151		f, err := os.Open(assetPath)
152		if err != nil {
153			return "", fmt.Errorf("could not open archive: %w", err)
154		}
155		defer f.Close() //nolint:errcheck
156		gzr, err := gzip.NewReader(f)
157		if err != nil {
158			return "", fmt.Errorf("could not create gzip reader: %w", err)
159		}
160		tr := tar.NewReader(gzr)
161		for {
162			hdr, err := tr.Next()
163			if err == io.EOF {
164				break
165			}
166			if err != nil {
167				return "", fmt.Errorf("error reading tar: %w", err)
168			}
169			name := filepath.Base(hdr.Name)
170			if name == binaryName || (strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg)) {
171				binPath = filepath.Join(tmpDir, binaryName)
172				out, err := os.Create(binPath)
173				if err != nil {
174					return "", fmt.Errorf("could not create binary file: %w", err)
175				}
176				if err := copyLimited(out, tr); err != nil {
177					_ = out.Close()
178					return "", fmt.Errorf("could not extract binary: %w", err)
179				}
180				if err := out.Close(); err != nil {
181					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
182				}
183				if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
184					return "", fmt.Errorf("could not make binary executable: %w", err)
185				}
186				break
187			}
188		}
189	} else if strings.HasSuffix(assetName, ".zip") {
190		zr, err := zip.OpenReader(assetPath)
191		if err != nil {
192			return "", fmt.Errorf("could not open zip archive: %w", err)
193		}
194		defer zr.Close() //nolint:errcheck
195		for _, zf := range zr.File {
196			name := filepath.Base(zf.Name)
197			if name == binaryName || (strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir()) {
198				rc, err := zf.Open()
199				if err != nil {
200					return "", fmt.Errorf("could not open file in zip: %w", err)
201				}
202				binPath = filepath.Join(tmpDir, binaryName)
203				out, err := os.Create(binPath)
204				if err != nil {
205					rc.Close() //nolint:errcheck,gosec
206					return "", fmt.Errorf("could not create binary file: %w", err)
207				}
208				if err := copyLimited(out, rc); err != nil {
209					_ = out.Close()
210					_ = rc.Close()
211					return "", fmt.Errorf("could not extract binary: %w", err)
212				}
213				if err := out.Close(); err != nil {
214					_ = rc.Close()
215					return "", fmt.Errorf("could not finalize extracted binary: %w", err)
216				}
217				if err := rc.Close(); err != nil {
218					return "", fmt.Errorf("could not close zip entry: %w", err)
219				}
220				if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
221					return "", fmt.Errorf("could not make binary executable: %w", err)
222				}
223				break
224			}
225		}
226	} else {
227		binPath = assetPath
228		if err := os.Chmod(binPath, 0755); err != nil { // #nosec G302 -- binary must be executable
229			fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
230		}
231	}
232
233	if binPath == "" {
234		return "", fmt.Errorf("could not locate matcha binary inside the release artifact")
235	}
236
237	return binPath, nil
238}
239
240// replaceExecutable atomically replaces the current executable with a new binary.
241func replaceExecutable(binPath, execDir string) error {
242	execPath, err := os.Executable()
243	if err != nil {
244		return fmt.Errorf("could not determine executable path: %w", err)
245	}
246
247	tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
248	in, err := os.Open(binPath)
249	if err != nil {
250		return fmt.Errorf("could not open new binary: %w", err)
251	}
252	defer in.Close()                                                          //nolint:errcheck
253	out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) // #nosec G302 -- binary must be executable
254	if err != nil {
255		return fmt.Errorf("could not create temp binary in target dir: %w", err)
256	}
257
258	defer func() {
259		cerr := out.Close()
260		if err == nil && cerr != nil {
261			err = fmt.Errorf("could not flush new binary to disk: %w", cerr)
262		}
263	}()
264
265	if _, err = io.Copy(out, in); err != nil {
266		return fmt.Errorf("could not write new binary to disk: %w", err)
267	}
268
269	if runtime.GOOS == goosWindows {
270		oldPath := execPath + ".old"
271		_ = os.Remove(oldPath)
272		if err := os.Rename(execPath, oldPath); err != nil {
273			return fmt.Errorf("could not move old executable out of the way: %w", err)
274		}
275	}
276
277	if err = os.Rename(tmpNew, execPath); err != nil {
278		return fmt.Errorf("could not replace executable: %w", err)
279	}
280
281	return nil
282}