1// Package updater provides update checking and downloading functionality.
2package updater
3
4import (
5 "archive/tar"
6 "bufio"
7 "compress/gzip"
8 "context"
9 "crypto/sha256"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "net/http"
16 "net/url"
17 "os"
18 "path/filepath"
19 "regexp"
20 "runtime"
21 "strconv"
22 "strings"
23 "time"
24
25 "github.com/bnema/dumber/internal/domain/entity"
26 "github.com/bnema/dumber/internal/logging"
27)
28
29const (
30 // GitHub API endpoint for latest release.
31 githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest"
32
33 // Download URL pattern for the release archive.
34 // Uses version-less filename for stable "latest" download URL.
35 downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz"
36
37 // Checksums file URL pattern.
38 checksumsURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/checksums.txt"
39
40 // HTTP client timeout for API requests.
41 apiTimeout = 10 * time.Second
42
43 // HTTP client timeout for downloads.
44 downloadTimeout = 5 * time.Minute
45
46 // File permission for directories and executables.
47 execPerm = 0o755
48
49 // Maximum size for extracted binary (100MB) - prevents decompression bombs.
50 maxBinarySize = 100 * 1024 * 1024
51
52 // Maximum archive download size (150MB) - prevents unbounded downloads.
53 maxArchiveSize = 150 * 1024 * 1024
54
55 // Minimum binary size (1MB) - prevents truncated/corrupt binaries.
56 minBinarySize = 1 * 1024 * 1024
57
58 // Allowed GitHub download host.
59 allowedDownloadHost = "github.com"
60)
61
62// githubRelease represents the GitHub API response for a release.
63type githubRelease struct {
64 TagName string `json:"tag_name"`
65 HTMLURL string `json:"html_url"`
66 PublishedAt time.Time `json:"published_at"`
67 Body string `json:"body"`
68}
69
70// GitHubChecker implements UpdateChecker using the GitHub API.
71type GitHubChecker struct {
72 client *http.Client
73}
74
75// NewGitHubChecker creates a new GitHub-based update checker.
76func NewGitHubChecker() *GitHubChecker {
77 return &GitHubChecker{
78 client: &http.Client{Timeout: apiTimeout},
79 }
80}
81
82// CheckForUpdate checks if a newer version is available on GitHub.
83func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) {
84 log := logging.FromContext(ctx)
85
86 // Skip check for dev builds.
87 if currentVersion == "" || currentVersion == "dev" {
88 log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build")
89 return &entity.UpdateInfo{
90 CurrentVersion: currentVersion,
91 LatestVersion: currentVersion,
92 IsNewer: false,
93 }, nil
94 }
95
96 req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
97 if err != nil {
98 return nil, fmt.Errorf("failed to create request: %w", err)
99 }
100 req.Header.Set("Accept", "application/vnd.github.v3+json")
101 req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
102
103 resp, err := g.client.Do(req)
104 if err != nil {
105 return nil, fmt.Errorf("failed to fetch latest release: %w", err)
106 }
107 defer func() { _ = resp.Body.Close() }()
108
109 if resp.StatusCode != http.StatusOK {
110 return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
111 }
112
113 var release githubRelease
114 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
115 return nil, fmt.Errorf("failed to decode release: %w", err)
116 }
117
118 latestVersion := strings.TrimPrefix(release.TagName, "v")
119 currentClean := strings.TrimPrefix(currentVersion, "v")
120
121 isNewer := compareVersions(currentClean, latestVersion) < 0
122
123 // Build download URL based on current architecture.
124 archName := getArchName()
125 downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName)
126
127 log.Debug().
128 Str("current", currentVersion).
129 Str("latest", latestVersion).
130 Bool("is_newer", isNewer).
131 Msg("update check completed")
132
133 return &entity.UpdateInfo{
134 CurrentVersion: currentVersion,
135 LatestVersion: latestVersion,
136 IsNewer: isNewer,
137 ReleaseURL: release.HTMLURL,
138 DownloadURL: downloadURL,
139 PublishedAt: release.PublishedAt,
140 ReleaseNotes: release.Body,
141 }, nil
142}
143
144// getArchName returns the architecture name used in release assets.
145func getArchName() string {
146 switch runtime.GOARCH {
147 case "amd64":
148 return "x86_64"
149 case "386":
150 return "i386"
151 default:
152 return runtime.GOARCH
153 }
154}
155
156// compareVersions compares two semantic versions.
157// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
158func compareVersions(v1, v2 string) int {
159 // Parse version components.
160 parse := func(v string) (major, minor, patch int) {
161 // Remove any pre-release suffix for comparison.
162 v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "")
163 parts := strings.Split(v, ".")
164 if len(parts) >= 1 {
165 major, _ = strconv.Atoi(parts[0])
166 }
167 if len(parts) >= 2 {
168 minor, _ = strconv.Atoi(parts[1])
169 }
170 if len(parts) >= 3 {
171 patch, _ = strconv.Atoi(parts[2])
172 }
173 return
174 }
175
176 maj1, min1, pat1 := parse(v1)
177 maj2, min2, pat2 := parse(v2)
178
179 if maj1 != maj2 {
180 if maj1 < maj2 {
181 return -1
182 }
183 return 1
184 }
185 if min1 != min2 {
186 if min1 < min2 {
187 return -1
188 }
189 return 1
190 }
191 if pat1 != pat2 {
192 if pat1 < pat2 {
193 return -1
194 }
195 return 1
196 }
197 return 0
198}
199
200// GitHubDownloader implements UpdateDownloader for GitHub releases.
201type GitHubDownloader struct {
202 client *http.Client
203}
204
205// NewGitHubDownloader creates a new GitHub release downloader.
206func NewGitHubDownloader() *GitHubDownloader {
207 return &GitHubDownloader{
208 client: &http.Client{Timeout: downloadTimeout},
209 }
210}
211
212// validateDownloadURL ensures the URL is a valid GitHub releases URL.
213func validateDownloadURL(downloadURL string) error {
214 parsed, err := url.Parse(downloadURL)
215 if err != nil {
216 return fmt.Errorf("invalid URL: %w", err)
217 }
218
219 if parsed.Scheme != "https" {
220 return fmt.Errorf("URL must use HTTPS, got %s", parsed.Scheme)
221 }
222
223 if parsed.Host != allowedDownloadHost {
224 return fmt.Errorf("URL must be from %s, got %s", allowedDownloadHost, parsed.Host)
225 }
226
227 // Verify it's a releases download path: /owner/repo/releases/...
228 if !strings.Contains(parsed.Path, "/releases/") {
229 return fmt.Errorf("URL must be a GitHub releases URL")
230 }
231
232 return nil
233}
234
235// fetchChecksums downloads the checksums.txt file and returns a map of filename -> sha256.
236func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]string, error) {
237 log := logging.FromContext(ctx)
238
239 req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURLTemplate, http.NoBody)
240 if err != nil {
241 return nil, fmt.Errorf("failed to create checksums request: %w", err)
242 }
243 req.Header.Set("User-Agent", "dumber-browser")
244
245 resp, err := g.client.Do(req)
246 if err != nil {
247 return nil, fmt.Errorf("failed to fetch checksums: %w", err)
248 }
249 defer func() { _ = resp.Body.Close() }()
250
251 if resp.StatusCode != http.StatusOK {
252 return nil, fmt.Errorf("checksums fetch failed with status %d", resp.StatusCode)
253 }
254
255 checksums := make(map[string]string)
256 scanner := bufio.NewScanner(resp.Body)
257 for scanner.Scan() {
258 line := strings.TrimSpace(scanner.Text())
259 if line == "" {
260 continue
261 }
262 // Format: "sha256 filename" (two spaces between)
263 parts := strings.Fields(line)
264 if len(parts) >= 2 {
265 hash := parts[0]
266 filename := parts[len(parts)-1] // Last field is filename
267 checksums[filename] = hash
268 }
269 }
270
271 if err := scanner.Err(); err != nil {
272 return nil, fmt.Errorf("failed to parse checksums: %w", err)
273 }
274
275 log.Debug().Int("count", len(checksums)).Msg("fetched checksums")
276 return checksums, nil
277}
278
279// verifyChecksum verifies the SHA256 checksum of a file.
280func verifyChecksum(filePath, expectedHash string) error {
281 file, err := os.Open(filePath)
282 if err != nil {
283 return fmt.Errorf("failed to open file for checksum: %w", err)
284 }
285 defer func() { _ = file.Close() }()
286
287 hash := sha256.New()
288 if _, err := io.Copy(hash, file); err != nil {
289 return fmt.Errorf("failed to compute checksum: %w", err)
290 }
291
292 actualHash := hex.EncodeToString(hash.Sum(nil))
293 if !strings.EqualFold(actualHash, expectedHash) {
294 return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
295 }
296
297 return nil
298}
299
300// getExpectedChecksum fetches checksums and returns the hash for the given URL.
301func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
302 checksums, err := g.fetchChecksums(ctx)
303 if err != nil {
304 return "", fmt.Errorf("failed to fetch checksums: %w", err)
305 }
306
307 parsedURL, err := url.Parse(downloadURL)
308 if err != nil {
309 return "", fmt.Errorf("failed to parse download URL: %w", err)
310 }
311
312 archiveFilename := filepath.Base(parsedURL.Path)
313 expectedHash, ok := checksums[archiveFilename]
314 if !ok {
315 return "", fmt.Errorf("no checksum found for %s", archiveFilename)
316 }
317
318 return expectedHash, nil
319}
320
321// Download fetches the update archive from the given URL.
322func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
323 log := logging.FromContext(ctx)
324
325 // Validate the download URL is from GitHub releases.
326 if err := validateDownloadURL(downloadURL); err != nil {
327 return "", fmt.Errorf("invalid download URL: %w", err)
328 }
329
330 // Ensure destination directory exists.
331 if err := os.MkdirAll(destDir, execPerm); err != nil {
332 return "", fmt.Errorf("failed to create download directory: %w", err)
333 }
334
335 // Fetch and look up expected checksum for this archive.
336 expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
337 if err != nil {
338 return "", err
339 }
340
341 // Create the request.
342 req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
343 if err != nil {
344 return "", fmt.Errorf("failed to create download request: %w", err)
345 }
346 req.Header.Set("User-Agent", "dumber-browser")
347
348 log.Debug().Str("url", downloadURL).Msg("downloading update")
349
350 resp, err := g.client.Do(req)
351 if err != nil {
352 return "", fmt.Errorf("failed to download: %w", err)
353 }
354 defer func() { _ = resp.Body.Close() }()
355
356 if resp.StatusCode != http.StatusOK {
357 return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
358 }
359
360 // Check Content-Length if available to enforce size limit.
361 if resp.ContentLength > maxArchiveSize {
362 return "", fmt.Errorf("archive too large: %d bytes (max %d)", resp.ContentLength, maxArchiveSize)
363 }
364
365 archivePath := filepath.Join(destDir, "dumber_update.tar.gz")
366
367 // Create the file.
368 file, err := os.Create(archivePath)
369 if err != nil {
370 return "", fmt.Errorf("failed to create archive file: %w", err)
371 }
372
373 // Use LimitReader to prevent unbounded downloads even if Content-Length is missing/spoofed.
374 limitedReader := io.LimitReader(resp.Body, maxArchiveSize+1)
375 written, err := io.Copy(file, limitedReader)
376 if closeErr := file.Close(); closeErr != nil && err == nil {
377 err = closeErr
378 }
379 if err != nil {
380 _ = os.Remove(archivePath)
381 return "", fmt.Errorf("failed to write archive: %w", err)
382 }
383
384 // Check if we hit the limit (means file was too large).
385 if written > maxArchiveSize {
386 _ = os.Remove(archivePath)
387 return "", fmt.Errorf("archive exceeds maximum size of %d bytes", maxArchiveSize)
388 }
389
390 // Verify checksum before returning.
391 if err := verifyChecksum(archivePath, expectedHash); err != nil {
392 _ = os.Remove(archivePath)
393 return "", fmt.Errorf("checksum verification failed: %w", err)
394 }
395
396 // Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
397 displayHash := expectedHash
398 if len(displayHash) > 16 {
399 displayHash = displayHash[:16] + "..."
400 }
401
402 log.Debug().
403 Int64("bytes", written).
404 Str("path", archivePath).
405 Str("checksum", displayHash).
406 Msg("download completed and verified")
407
408 return archivePath, nil
409}
410
411// sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
412func sanitizeTarPath(name, destDir string) (string, error) {
413 // Clean the path to remove any . or .. components.
414 cleaned := filepath.Clean(name)
415
416 // Reject absolute paths.
417 if filepath.IsAbs(cleaned) {
418 return "", fmt.Errorf("absolute path not allowed: %s", name)
419 }
420
421 // Reject paths that contain ".." as a path component (not as part of filename).
422 // Split by separator and check each component.
423 for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
424 if part == ".." {
425 return "", fmt.Errorf("path traversal detected: %s", name)
426 }
427 }
428
429 // Join with destination and verify result is within destDir.
430 fullPath := filepath.Join(destDir, cleaned)
431 absDestDir, err := filepath.Abs(destDir)
432 if err != nil {
433 return "", fmt.Errorf("failed to get absolute dest path: %w", err)
434 }
435 absFullPath, err := filepath.Abs(fullPath)
436 if err != nil {
437 return "", fmt.Errorf("failed to get absolute full path: %w", err)
438 }
439
440 // Ensure the resolved path is within destDir.
441 if !strings.HasPrefix(absFullPath, absDestDir+string(filepath.Separator)) && absFullPath != absDestDir {
442 return "", fmt.Errorf("path escapes destination directory: %s", name)
443 }
444
445 return fullPath, nil
446}
447
448// Extract extracts the dumber binary from the tar.gz archive.
449func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) {
450 log := logging.FromContext(ctx)
451
452 file, err := os.Open(archivePath)
453 if err != nil {
454 return "", fmt.Errorf("failed to open archive: %w", err)
455 }
456 defer func() { _ = file.Close() }()
457
458 gzr, err := gzip.NewReader(file)
459 if err != nil {
460 return "", fmt.Errorf("failed to create gzip reader: %w", err)
461 }
462 defer func() { _ = gzr.Close() }()
463
464 tr := tar.NewReader(gzr)
465
466 var binaryPath string
467
468 for {
469 header, err := tr.Next()
470 if err == io.EOF {
471 break
472 }
473 if err != nil {
474 return "", fmt.Errorf("failed to read tar: %w", err)
475 }
476
477 // Look for the dumber binary (inside dumber_VERSION/ directory).
478 if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") {
479 // Sanitize the path to prevent path traversal attacks.
480 _, err := sanitizeTarPath(header.Name, destDir)
481 if err != nil {
482 return "", fmt.Errorf("invalid tar entry: %w", err)
483 }
484
485 // We always extract to a fixed name regardless of archive path.
486 binaryPath = filepath.Join(destDir, "dumber")
487
488 outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm)
489 if err != nil {
490 return "", fmt.Errorf("failed to create binary file: %w", err)
491 }
492
493 // Limit copy to prevent decompression bombs (G110).
494 written, err := io.CopyN(outFile, tr, maxBinarySize)
495 if closeErr := outFile.Close(); closeErr != nil && err == nil {
496 err = closeErr
497 }
498 if err != nil && !errors.Is(err, io.EOF) {
499 _ = os.Remove(binaryPath)
500 return "", fmt.Errorf("failed to extract binary: %w", err)
501 }
502
503 // Verify minimum binary size to detect truncated/corrupt files.
504 if written < minBinarySize {
505 _ = os.Remove(binaryPath)
506 return "", fmt.Errorf("binary too small (%d bytes), expected at least %d bytes", written, minBinarySize)
507 }
508
509 log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary")
510 break
511 }
512 }
513
514 if binaryPath == "" {
515 return "", fmt.Errorf("dumber binary not found in archive")
516 }
517
518 return binaryPath, nil
519}