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}