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 "math/rand"
16 "net"
17 "net/http"
18 "net/url"
19 "os"
20 "path/filepath"
21 "regexp"
22 "runtime"
23 "strconv"
24 "strings"
25 "syscall"
26 "time"
27
28 "github.com/bnema/dumber/internal/application/port"
29 "github.com/bnema/dumber/internal/domain/entity"
30 "github.com/bnema/dumber/internal/infrastructure/env"
31 "github.com/bnema/dumber/internal/logging"
32)
33
34const (
35 // GitHub API endpoint for latest release.
36 githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest"
37
38 // Download URL pattern for the release archive.
39 // Uses version-less filename for stable "latest" download URL.
40 downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz"
41
42 // Checksums file URL pattern.
43 checksumsURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/checksums.txt"
44
45 // HTTP client timeout for API requests.
46 apiTimeout = 10 * time.Second
47
48 // HTTP client timeout for downloads.
49 downloadTimeout = 5 * time.Minute
50
51 // File permission for directories and executables.
52 execPerm = 0o755
53
54 // Maximum size for extracted binary (100MB) - prevents decompression bombs.
55 maxBinarySize = 100 * 1024 * 1024
56
57 // Maximum archive download size (150MB) - prevents unbounded downloads.
58 maxArchiveSize = 150 * 1024 * 1024
59
60 // Minimum binary size (1MB) - prevents truncated/corrupt binaries.
61 minBinarySize = 1 * 1024 * 1024
62
63 // Allowed GitHub download host.
64 allowedDownloadHost = "github.com"
65
66 // Maximum number of attempts for retryable network requests.
67 maxRetryAttempts = 3
68
69 // Base delay used for exponential backoff between retries.
70 retryBaseDelay = 250 * time.Millisecond
71
72 // Maximum delay cap for exponential backoff between retries.
73 retryMaxDelay = 2 * time.Second
74
75 // Max random jitter added to each retry backoff.
76 retryJitterMax = 200 * time.Millisecond
77)
78
79// githubRelease represents the GitHub API response for a release.
80type githubRelease struct {
81 TagName string `json:"tag_name"`
82 HTMLURL string `json:"html_url"`
83 PublishedAt time.Time `json:"published_at"`
84 Body string `json:"body"`
85}
86
87// GitHubChecker implements UpdateChecker using the GitHub API.
88type GitHubChecker struct {
89 client *http.Client
90 randInt63 func(n int64) int64
91 sleep func(ctx context.Context, d time.Duration) error
92}
93
94// NewGitHubChecker creates a new GitHub-based update checker.
95func NewGitHubChecker() *GitHubChecker {
96 return &GitHubChecker{
97 client: &http.Client{Timeout: apiTimeout},
98 randInt63: rand.Int63n,
99 sleep: waitForBackoff,
100 }
101}
102
103// CheckForUpdate checks if a newer version is available on GitHub.
104func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) {
105 log := logging.FromContext(ctx)
106
107 // Skip check for dev builds.
108 if currentVersion == "" || currentVersion == "dev" {
109 log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build")
110 return &entity.UpdateInfo{
111 CurrentVersion: currentVersion,
112 LatestVersion: currentVersion,
113 IsNewer: false,
114 }, nil
115 }
116
117 // Skip check for package manager installs (Flatpak, AUR).
118 // These should use their respective package managers for updates.
119 if env.IsFlatpak() || env.IsPacman() {
120 log.Debug().
121 Bool("flatpak", env.IsFlatpak()).
122 Bool("pacman", env.IsPacman()).
123 Msg("skipping update check for package manager install")
124 return &entity.UpdateInfo{
125 CurrentVersion: currentVersion,
126 LatestVersion: currentVersion,
127 IsNewer: false,
128 }, nil
129 }
130
131 req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
132 if err != nil {
133 return nil, fmt.Errorf("failed to create request: %w", err)
134 }
135 req.Header.Set("Accept", "application/vnd.github.v3+json")
136 req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
137
138 resp, err := g.doRequestWithRetry(ctx, req)
139 if err != nil {
140 if isRetryableRequestError(err) {
141 log.Debug().Err(err).Msg("transient update check failure; skipping check")
142 return nil, fmt.Errorf("%w: %w", port.ErrUpdateCheckTransient, err)
143 }
144 return nil, fmt.Errorf("failed to fetch latest release: %w", err)
145 }
146 defer func() { _ = resp.Body.Close() }()
147
148 if resp.StatusCode != http.StatusOK {
149 if isRetryableStatus(resp.StatusCode) {
150 log.Debug().Int("status", resp.StatusCode).Msg("transient update check API status; skipping check")
151 return nil, fmt.Errorf("%w: github API returned status %d", port.ErrUpdateCheckTransient, resp.StatusCode)
152 }
153 return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
154 }
155
156 var release githubRelease
157 if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
158 return nil, fmt.Errorf("failed to decode release: %w", err)
159 }
160
161 latestVersion := strings.TrimPrefix(release.TagName, "v")
162 currentClean := strings.TrimPrefix(currentVersion, "v")
163
164 isNewer := compareVersions(currentClean, latestVersion) < 0
165
166 // Build download URL based on current architecture.
167 archName := getArchName()
168 downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName)
169
170 log.Debug().
171 Str("current", currentVersion).
172 Str("latest", latestVersion).
173 Bool("is_newer", isNewer).
174 Msg("update check completed")
175
176 return &entity.UpdateInfo{
177 CurrentVersion: currentVersion,
178 LatestVersion: latestVersion,
179 IsNewer: isNewer,
180 ReleaseURL: release.HTMLURL,
181 DownloadURL: downloadURL,
182 PublishedAt: release.PublishedAt,
183 ReleaseNotes: release.Body,
184 }, nil
185}
186
187// getArchName returns the architecture name used in release assets.
188func getArchName() string {
189 switch runtime.GOARCH {
190 case "amd64":
191 return "x86_64"
192 case "386":
193 return "i386"
194 default:
195 return runtime.GOARCH
196 }
197}
198
199// compareVersions compares two semantic versions.
200// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
201func compareVersions(v1, v2 string) int {
202 // Parse version components.
203 parse := func(v string) (major, minor, patch int) {
204 // Remove any pre-release suffix for comparison.
205 v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "")
206 parts := strings.Split(v, ".")
207 if len(parts) >= 1 {
208 major, _ = strconv.Atoi(parts[0])
209 }
210 if len(parts) >= 2 {
211 minor, _ = strconv.Atoi(parts[1])
212 }
213 if len(parts) >= 3 {
214 patch, _ = strconv.Atoi(parts[2])
215 }
216 return
217 }
218
219 maj1, min1, pat1 := parse(v1)
220 maj2, min2, pat2 := parse(v2)
221
222 if maj1 != maj2 {
223 if maj1 < maj2 {
224 return -1
225 }
226 return 1
227 }
228 if min1 != min2 {
229 if min1 < min2 {
230 return -1
231 }
232 return 1
233 }
234 if pat1 != pat2 {
235 if pat1 < pat2 {
236 return -1
237 }
238 return 1
239 }
240 return 0
241}
242
243// GitHubDownloader implements UpdateDownloader for GitHub releases.
244type GitHubDownloader struct {
245 client *http.Client
246 randInt63 func(n int64) int64
247 sleep func(ctx context.Context, d time.Duration) error
248}
249
250// NewGitHubDownloader creates a new GitHub release downloader.
251func NewGitHubDownloader() *GitHubDownloader {
252 return &GitHubDownloader{
253 client: &http.Client{Timeout: downloadTimeout},
254 randInt63: rand.Int63n,
255 sleep: waitForBackoff,
256 }
257}
258
259func isRetryableStatus(status int) bool {
260 if status == http.StatusForbidden || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout {
261 return true
262 }
263 return status >= http.StatusInternalServerError
264}
265
266func isRetryableRequestError(err error) bool {
267 if err == nil {
268 return false
269 }
270 if errors.Is(err, context.Canceled) {
271 return false
272 }
273 if errors.Is(err, context.DeadlineExceeded) {
274 return true
275 }
276
277 var netErr net.Error
278 if errors.As(err, &netErr) && netErr.Timeout() {
279 return true
280 }
281
282 var dnsErr *net.DNSError
283 if errors.As(err, &dnsErr) && dnsErr.IsTemporary {
284 return true
285 }
286
287 var opErr *net.OpError
288 if errors.As(err, &opErr) {
289 if isTransientSyscallError(opErr.Err) {
290 return true
291 }
292 }
293
294 return false
295}
296
297func isTransientSyscallError(err error) bool {
298 var errno syscall.Errno
299 if !errors.As(err, &errno) {
300 return false
301 }
302 switch errno {
303 case syscall.ECONNRESET, syscall.ECONNREFUSED,
304 syscall.EADDRNOTAVAIL, syscall.ENETUNREACH,
305 syscall.EHOSTUNREACH:
306 return true
307 }
308 return false
309}
310
311func waitForBackoff(ctx context.Context, d time.Duration) error {
312 timer := time.NewTimer(d)
313 defer timer.Stop()
314
315 select {
316 case <-ctx.Done():
317 return ctx.Err()
318 case <-timer.C:
319 return nil
320 }
321}
322
323func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration {
324 if attempt < 1 {
325 attempt = 1
326 }
327 delay := retryBaseDelay
328 for i := 1; i < attempt; i++ {
329 if delay >= retryMaxDelay {
330 delay = retryMaxDelay
331 break
332 }
333 delay *= 2
334 if delay > retryMaxDelay {
335 delay = retryMaxDelay
336 }
337 }
338
339 if randInt63 != nil && retryJitterMax > 0 {
340 delay += time.Duration(randInt63(int64(retryJitterMax)))
341 }
342
343 if delay > retryMaxDelay {
344 delay = retryMaxDelay
345 }
346
347 return delay
348}
349
350// doRequestWithRetryHelper retries the same request instance across attempts.
351// It assumes req.Body is non-consumable (for example http.NoBody); it does not clone
352// the request or rewind req.Body. Callers needing retries with a real body must provide
353// a fresh cloned request per attempt or use a rewindable body implementation.
354func doRequestWithRetryHelper(
355 ctx context.Context,
356 client *http.Client,
357 req *http.Request,
358 sleep func(ctx context.Context, d time.Duration) error,
359 randInt63 func(n int64) int64,
360) (*http.Response, error) {
361 for attempt := 1; ; attempt++ {
362 if attempt > maxRetryAttempts {
363 return nil, fmt.Errorf("request failed after retries")
364 }
365 resp, err := client.Do(req)
366 if err != nil {
367 if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
368 return nil, err
369 }
370 if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
371 return nil, waitErr
372 }
373 continue
374 }
375
376 if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts {
377 return resp, nil
378 }
379
380 _ = resp.Body.Close()
381 if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
382 return nil, waitErr
383 }
384 }
385}
386
387func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
388 return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
389}
390
391// validateDownloadURL ensures the URL is a valid GitHub releases URL.
392func validateDownloadURL(downloadURL string) error {
393 parsed, err := url.Parse(downloadURL)
394 if err != nil {
395 return fmt.Errorf("invalid URL: %w", err)
396 }
397
398 if parsed.Scheme != "https" {
399 return fmt.Errorf("URL must use HTTPS, got %s", parsed.Scheme)
400 }
401
402 if parsed.Host != allowedDownloadHost {
403 return fmt.Errorf("URL must be from %s, got %s", allowedDownloadHost, parsed.Host)
404 }
405
406 // Verify it's a releases download path: /owner/repo/releases/...
407 if !strings.Contains(parsed.Path, "/releases/") {
408 return fmt.Errorf("URL must be a GitHub releases URL")
409 }
410
411 return nil
412}
413
414// fetchChecksums downloads the checksums.txt file and returns a map of filename -> sha256.
415func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]string, error) {
416 log := logging.FromContext(ctx)
417
418 req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURLTemplate, http.NoBody)
419 if err != nil {
420 return nil, fmt.Errorf("failed to create checksums request: %w", err)
421 }
422 req.Header.Set("User-Agent", "dumber-browser")
423
424 resp, err := g.doRequestWithRetry(ctx, req)
425 if err != nil {
426 return nil, fmt.Errorf("failed to fetch checksums: %w", err)
427 }
428 defer func() { _ = resp.Body.Close() }()
429
430 if resp.StatusCode != http.StatusOK {
431 return nil, fmt.Errorf("checksums fetch failed with status %d", resp.StatusCode)
432 }
433
434 checksums := make(map[string]string)
435 scanner := bufio.NewScanner(resp.Body)
436 for scanner.Scan() {
437 line := strings.TrimSpace(scanner.Text())
438 if line == "" {
439 continue
440 }
441 // Format: "sha256 filename" (two spaces between)
442 parts := strings.Fields(line)
443 if len(parts) >= 2 {
444 hash := parts[0]
445 filename := parts[len(parts)-1] // Last field is filename
446 checksums[filename] = hash
447 }
448 }
449
450 if err := scanner.Err(); err != nil {
451 return nil, fmt.Errorf("failed to parse checksums: %w", err)
452 }
453
454 log.Debug().Int("count", len(checksums)).Msg("fetched checksums")
455 return checksums, nil
456}
457
458// verifyChecksum verifies the SHA256 checksum of a file.
459func verifyChecksum(filePath, expectedHash string) error {
460 file, err := os.Open(filePath)
461 if err != nil {
462 return fmt.Errorf("failed to open file for checksum: %w", err)
463 }
464 defer func() { _ = file.Close() }()
465
466 hash := sha256.New()
467 if _, err := io.Copy(hash, file); err != nil {
468 return fmt.Errorf("failed to compute checksum: %w", err)
469 }
470
471 actualHash := hex.EncodeToString(hash.Sum(nil))
472 if !strings.EqualFold(actualHash, expectedHash) {
473 return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
474 }
475
476 return nil
477}
478
479// getExpectedChecksum fetches checksums and returns the hash for the given URL.
480func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
481 checksums, err := g.fetchChecksums(ctx)
482 if err != nil {
483 return "", fmt.Errorf("failed to fetch checksums: %w", err)
484 }
485
486 parsedURL, err := url.Parse(downloadURL)
487 if err != nil {
488 return "", fmt.Errorf("failed to parse download URL: %w", err)
489 }
490
491 archiveFilename := filepath.Base(parsedURL.Path)
492 expectedHash, ok := checksums[archiveFilename]
493 if !ok {
494 return "", fmt.Errorf("no checksum found for %s", archiveFilename)
495 }
496
497 return expectedHash, nil
498}
499
500// Download fetches the update archive from the given URL.
501func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
502 log := logging.FromContext(ctx)
503
504 // Validate the download URL is from GitHub releases.
505 if err := validateDownloadURL(downloadURL); err != nil {
506 return "", fmt.Errorf("invalid download URL: %w", err)
507 }
508
509 // Ensure destination directory exists.
510 if err := os.MkdirAll(destDir, execPerm); err != nil {
511 return "", fmt.Errorf("failed to create download directory: %w", err)
512 }
513
514 // Fetch and look up expected checksum for this archive.
515 expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
516 if err != nil {
517 return "", err
518 }
519
520 // Create the request.
521 req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
522 if err != nil {
523 return "", fmt.Errorf("failed to create download request: %w", err)
524 }
525 req.Header.Set("User-Agent", "dumber-browser")
526
527 log.Debug().Str("url", downloadURL).Msg("downloading update")
528
529 resp, err := g.doRequestWithRetry(ctx, req)
530 if err != nil {
531 return "", fmt.Errorf("failed to download: %w", err)
532 }
533 defer func() { _ = resp.Body.Close() }()
534
535 if resp.StatusCode != http.StatusOK {
536 return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
537 }
538
539 // Check Content-Length if available to enforce size limit.
540 if resp.ContentLength > maxArchiveSize {
541 return "", fmt.Errorf("archive too large: %d bytes (max %d)", resp.ContentLength, maxArchiveSize)
542 }
543
544 archivePath := filepath.Join(destDir, "dumber_update.tar.gz")
545
546 // Create the file.
547 file, err := os.Create(archivePath)
548 if err != nil {
549 return "", fmt.Errorf("failed to create archive file: %w", err)
550 }
551
552 // Use LimitReader to prevent unbounded downloads even if Content-Length is missing/spoofed.
553 limitedReader := io.LimitReader(resp.Body, maxArchiveSize+1)
554 written, err := io.Copy(file, limitedReader)
555 if closeErr := file.Close(); closeErr != nil && err == nil {
556 err = closeErr
557 }
558 if err != nil {
559 _ = os.Remove(archivePath)
560 return "", fmt.Errorf("failed to write archive: %w", err)
561 }
562
563 // Check if we hit the limit (means file was too large).
564 if written > maxArchiveSize {
565 _ = os.Remove(archivePath)
566 return "", fmt.Errorf("archive exceeds maximum size of %d bytes", maxArchiveSize)
567 }
568
569 // Verify checksum before returning.
570 if err := verifyChecksum(archivePath, expectedHash); err != nil {
571 _ = os.Remove(archivePath)
572 return "", fmt.Errorf("checksum verification failed: %w", err)
573 }
574
575 // Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
576 displayHash := expectedHash
577 if len(displayHash) > 16 {
578 displayHash = displayHash[:16] + "..."
579 }
580
581 log.Debug().
582 Int64("bytes", written).
583 Str("path", archivePath).
584 Str("checksum", displayHash).
585 Msg("download completed and verified")
586
587 return archivePath, nil
588}
589
590func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
591 return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
592}
593
594// sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
595func sanitizeTarPath(name, destDir string) (string, error) {
596 // Clean the path to remove any . or .. components.
597 cleaned := filepath.Clean(name)
598
599 // Reject absolute paths.
600 if filepath.IsAbs(cleaned) {
601 return "", fmt.Errorf("absolute path not allowed: %s", name)
602 }
603
604 // Reject paths that contain ".." as a path component (not as part of filename).
605 // Split by separator and check each component.
606 for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
607 if part == ".." {
608 return "", fmt.Errorf("path traversal detected: %s", name)
609 }
610 }
611
612 // Join with destination and verify result is within destDir.
613 fullPath := filepath.Join(destDir, cleaned)
614 absDestDir, err := filepath.Abs(destDir)
615 if err != nil {
616 return "", fmt.Errorf("failed to get absolute dest path: %w", err)
617 }
618 absFullPath, err := filepath.Abs(fullPath)
619 if err != nil {
620 return "", fmt.Errorf("failed to get absolute full path: %w", err)
621 }
622
623 // Ensure the resolved path is within destDir.
624 if !strings.HasPrefix(absFullPath, absDestDir+string(filepath.Separator)) && absFullPath != absDestDir {
625 return "", fmt.Errorf("path escapes destination directory: %s", name)
626 }
627
628 return fullPath, nil
629}
630
631// Extract extracts the dumber binary from the tar.gz archive.
632func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) {
633 log := logging.FromContext(ctx)
634
635 file, err := os.Open(archivePath)
636 if err != nil {
637 return "", fmt.Errorf("failed to open archive: %w", err)
638 }
639 defer func() { _ = file.Close() }()
640
641 gzr, err := gzip.NewReader(file)
642 if err != nil {
643 return "", fmt.Errorf("failed to create gzip reader: %w", err)
644 }
645 defer func() { _ = gzr.Close() }()
646
647 tr := tar.NewReader(gzr)
648
649 var binaryPath string
650
651 for {
652 header, err := tr.Next()
653 if err == io.EOF {
654 break
655 }
656 if err != nil {
657 return "", fmt.Errorf("failed to read tar: %w", err)
658 }
659
660 // Look for the dumber binary (inside dumber_VERSION/ directory).
661 if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") {
662 // Sanitize the path to prevent path traversal attacks.
663 _, err := sanitizeTarPath(header.Name, destDir)
664 if err != nil {
665 return "", fmt.Errorf("invalid tar entry: %w", err)
666 }
667
668 // We always extract to a fixed name regardless of archive path.
669 binaryPath = filepath.Join(destDir, "dumber")
670
671 outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm)
672 if err != nil {
673 return "", fmt.Errorf("failed to create binary file: %w", err)
674 }
675
676 // Limit copy to prevent decompression bombs (G110).
677 written, err := io.CopyN(outFile, tr, maxBinarySize)
678 if closeErr := outFile.Close(); closeErr != nil && err == nil {
679 err = closeErr
680 }
681 if err != nil && !errors.Is(err, io.EOF) {
682 _ = os.Remove(binaryPath)
683 return "", fmt.Errorf("failed to extract binary: %w", err)
684 }
685
686 // Verify minimum binary size to detect truncated/corrupt files.
687 if written < minBinarySize {
688 _ = os.Remove(binaryPath)
689 return "", fmt.Errorf("binary too small (%d bytes), expected at least %d bytes", written, minBinarySize)
690 }
691
692 log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary")
693 break
694 }
695 }
696
697 if binaryPath == "" {
698 return "", fmt.Errorf("dumber binary not found in archive")
699 }
700
701 return binaryPath, nil
702}