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) {
279 if netErr.Timeout() {
280 return true
281 }
282 //nolint:staticcheck // net.Error.Temporary is deprecated but still useful for transient detection
283 if netErr.Temporary() {
284 return true
285 }
286 }
287
288 var opErr *net.OpError
289 if errors.As(err, &opErr) {
290 if isTransientSyscallError(opErr.Err) {
291 return true
292 }
293 }
294
295 return false
296}
297
298func isTransientSyscallError(err error) bool {
299 var errno syscall.Errno
300 if !errors.As(err, &errno) {
301 return false
302 }
303 switch errno {
304 case syscall.ECONNRESET, syscall.ECONNREFUSED,
305 syscall.EADDRNOTAVAIL, syscall.ENETUNREACH,
306 syscall.EHOSTUNREACH:
307 return true
308 }
309 return false
310}
311
312func waitForBackoff(ctx context.Context, d time.Duration) error {
313 timer := time.NewTimer(d)
314 defer timer.Stop()
315
316 select {
317 case <-ctx.Done():
318 return ctx.Err()
319 case <-timer.C:
320 return nil
321 }
322}
323
324func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration {
325 if attempt < 1 {
326 attempt = 1
327 }
328 delay := retryBaseDelay
329 for i := 1; i < attempt; i++ {
330 if delay >= retryMaxDelay {
331 delay = retryMaxDelay
332 break
333 }
334 delay *= 2
335 if delay > retryMaxDelay {
336 delay = retryMaxDelay
337 }
338 }
339
340 if randInt63 != nil && retryJitterMax > 0 {
341 delay += time.Duration(randInt63(int64(retryJitterMax)))
342 }
343
344 if delay > retryMaxDelay {
345 delay = retryMaxDelay
346 }
347
348 return delay
349}
350
351// doRequestWithRetryHelper retries the same request instance across attempts.
352// It assumes req.Body is non-consumable (for example http.NoBody); it does not clone
353// the request or rewind req.Body. Callers needing retries with a real body must provide
354// a fresh cloned request per attempt or use a rewindable body implementation.
355func doRequestWithRetryHelper(
356 ctx context.Context,
357 client *http.Client,
358 req *http.Request,
359 sleep func(ctx context.Context, d time.Duration) error,
360 randInt63 func(n int64) int64,
361) (*http.Response, error) {
362 for attempt := 1; ; attempt++ {
363 if attempt > maxRetryAttempts {
364 return nil, fmt.Errorf("request failed after retries")
365 }
366 resp, err := client.Do(req)
367 if err != nil {
368 if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
369 return nil, err
370 }
371 if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
372 return nil, waitErr
373 }
374 continue
375 }
376
377 if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts {
378 return resp, nil
379 }
380
381 _ = resp.Body.Close()
382 if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
383 return nil, waitErr
384 }
385 }
386}
387
388func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
389 return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
390}
391
392// validateDownloadURL ensures the URL is a valid GitHub releases URL.
393func validateDownloadURL(downloadURL string) error {
394 parsed, err := url.Parse(downloadURL)
395 if err != nil {
396 return fmt.Errorf("invalid URL: %w", err)
397 }
398
399 if parsed.Scheme != "https" {
400 return fmt.Errorf("URL must use HTTPS, got %s", parsed.Scheme)
401 }
402
403 if parsed.Host != allowedDownloadHost {
404 return fmt.Errorf("URL must be from %s, got %s", allowedDownloadHost, parsed.Host)
405 }
406
407 // Verify it's a releases download path: /owner/repo/releases/...
408 if !strings.Contains(parsed.Path, "/releases/") {
409 return fmt.Errorf("URL must be a GitHub releases URL")
410 }
411
412 return nil
413}
414
415// fetchChecksums downloads the checksums.txt file and returns a map of filename -> sha256.
416func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]string, error) {
417 log := logging.FromContext(ctx)
418
419 req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURLTemplate, http.NoBody)
420 if err != nil {
421 return nil, fmt.Errorf("failed to create checksums request: %w", err)
422 }
423 req.Header.Set("User-Agent", "dumber-browser")
424
425 resp, err := g.doRequestWithRetry(ctx, req)
426 if err != nil {
427 return nil, fmt.Errorf("failed to fetch checksums: %w", err)
428 }
429 defer func() { _ = resp.Body.Close() }()
430
431 if resp.StatusCode != http.StatusOK {
432 return nil, fmt.Errorf("checksums fetch failed with status %d", resp.StatusCode)
433 }
434
435 checksums := make(map[string]string)
436 scanner := bufio.NewScanner(resp.Body)
437 for scanner.Scan() {
438 line := strings.TrimSpace(scanner.Text())
439 if line == "" {
440 continue
441 }
442 // Format: "sha256 filename" (two spaces between)
443 parts := strings.Fields(line)
444 if len(parts) >= 2 {
445 hash := parts[0]
446 filename := parts[len(parts)-1] // Last field is filename
447 checksums[filename] = hash
448 }
449 }
450
451 if err := scanner.Err(); err != nil {
452 return nil, fmt.Errorf("failed to parse checksums: %w", err)
453 }
454
455 log.Debug().Int("count", len(checksums)).Msg("fetched checksums")
456 return checksums, nil
457}
458
459// verifyChecksum verifies the SHA256 checksum of a file.
460func verifyChecksum(filePath, expectedHash string) error {
461 file, err := os.Open(filePath)
462 if err != nil {
463 return fmt.Errorf("failed to open file for checksum: %w", err)
464 }
465 defer func() { _ = file.Close() }()
466
467 hash := sha256.New()
468 if _, err := io.Copy(hash, file); err != nil {
469 return fmt.Errorf("failed to compute checksum: %w", err)
470 }
471
472 actualHash := hex.EncodeToString(hash.Sum(nil))
473 if !strings.EqualFold(actualHash, expectedHash) {
474 return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
475 }
476
477 return nil
478}
479
480// getExpectedChecksum fetches checksums and returns the hash for the given URL.
481func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
482 checksums, err := g.fetchChecksums(ctx)
483 if err != nil {
484 return "", fmt.Errorf("failed to fetch checksums: %w", err)
485 }
486
487 parsedURL, err := url.Parse(downloadURL)
488 if err != nil {
489 return "", fmt.Errorf("failed to parse download URL: %w", err)
490 }
491
492 archiveFilename := filepath.Base(parsedURL.Path)
493 expectedHash, ok := checksums[archiveFilename]
494 if !ok {
495 return "", fmt.Errorf("no checksum found for %s", archiveFilename)
496 }
497
498 return expectedHash, nil
499}
500
501// Download fetches the update archive from the given URL.
502func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
503 log := logging.FromContext(ctx)
504
505 // Validate the download URL is from GitHub releases.
506 if err := validateDownloadURL(downloadURL); err != nil {
507 return "", fmt.Errorf("invalid download URL: %w", err)
508 }
509
510 // Ensure destination directory exists.
511 if err := os.MkdirAll(destDir, execPerm); err != nil {
512 return "", fmt.Errorf("failed to create download directory: %w", err)
513 }
514
515 // Fetch and look up expected checksum for this archive.
516 expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
517 if err != nil {
518 return "", err
519 }
520
521 // Create the request.
522 req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
523 if err != nil {
524 return "", fmt.Errorf("failed to create download request: %w", err)
525 }
526 req.Header.Set("User-Agent", "dumber-browser")
527
528 log.Debug().Str("url", downloadURL).Msg("downloading update")
529
530 resp, err := g.doRequestWithRetry(ctx, req)
531 if err != nil {
532 return "", fmt.Errorf("failed to download: %w", err)
533 }
534 defer func() { _ = resp.Body.Close() }()
535
536 if resp.StatusCode != http.StatusOK {
537 return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
538 }
539
540 // Check Content-Length if available to enforce size limit.
541 if resp.ContentLength > maxArchiveSize {
542 return "", fmt.Errorf("archive too large: %d bytes (max %d)", resp.ContentLength, maxArchiveSize)
543 }
544
545 archivePath := filepath.Join(destDir, "dumber_update.tar.gz")
546
547 // Create the file.
548 file, err := os.Create(archivePath)
549 if err != nil {
550 return "", fmt.Errorf("failed to create archive file: %w", err)
551 }
552
553 // Use LimitReader to prevent unbounded downloads even if Content-Length is missing/spoofed.
554 limitedReader := io.LimitReader(resp.Body, maxArchiveSize+1)
555 written, err := io.Copy(file, limitedReader)
556 if closeErr := file.Close(); closeErr != nil && err == nil {
557 err = closeErr
558 }
559 if err != nil {
560 _ = os.Remove(archivePath)
561 return "", fmt.Errorf("failed to write archive: %w", err)
562 }
563
564 // Check if we hit the limit (means file was too large).
565 if written > maxArchiveSize {
566 _ = os.Remove(archivePath)
567 return "", fmt.Errorf("archive exceeds maximum size of %d bytes", maxArchiveSize)
568 }
569
570 // Verify checksum before returning.
571 if err := verifyChecksum(archivePath, expectedHash); err != nil {
572 _ = os.Remove(archivePath)
573 return "", fmt.Errorf("checksum verification failed: %w", err)
574 }
575
576 // Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
577 displayHash := expectedHash
578 if len(displayHash) > 16 {
579 displayHash = displayHash[:16] + "..."
580 }
581
582 log.Debug().
583 Int64("bytes", written).
584 Str("path", archivePath).
585 Str("checksum", displayHash).
586 Msg("download completed and verified")
587
588 return archivePath, nil
589}
590
591func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
592 return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
593}
594
595// sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
596func sanitizeTarPath(name, destDir string) (string, error) {
597 // Clean the path to remove any . or .. components.
598 cleaned := filepath.Clean(name)
599
600 // Reject absolute paths.
601 if filepath.IsAbs(cleaned) {
602 return "", fmt.Errorf("absolute path not allowed: %s", name)
603 }
604
605 // Reject paths that contain ".." as a path component (not as part of filename).
606 // Split by separator and check each component.
607 for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
608 if part == ".." {
609 return "", fmt.Errorf("path traversal detected: %s", name)
610 }
611 }
612
613 // Join with destination and verify result is within destDir.
614 fullPath := filepath.Join(destDir, cleaned)
615 absDestDir, err := filepath.Abs(destDir)
616 if err != nil {
617 return "", fmt.Errorf("failed to get absolute dest path: %w", err)
618 }
619 absFullPath, err := filepath.Abs(fullPath)
620 if err != nil {
621 return "", fmt.Errorf("failed to get absolute full path: %w", err)
622 }
623
624 // Ensure the resolved path is within destDir.
625 if !strings.HasPrefix(absFullPath, absDestDir+string(filepath.Separator)) && absFullPath != absDestDir {
626 return "", fmt.Errorf("path escapes destination directory: %s", name)
627 }
628
629 return fullPath, nil
630}
631
632// Extract extracts the dumber binary from the tar.gz archive.
633func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) {
634 log := logging.FromContext(ctx)
635
636 file, err := os.Open(archivePath)
637 if err != nil {
638 return "", fmt.Errorf("failed to open archive: %w", err)
639 }
640 defer func() { _ = file.Close() }()
641
642 gzr, err := gzip.NewReader(file)
643 if err != nil {
644 return "", fmt.Errorf("failed to create gzip reader: %w", err)
645 }
646 defer func() { _ = gzr.Close() }()
647
648 tr := tar.NewReader(gzr)
649
650 var binaryPath string
651
652 for {
653 header, err := tr.Next()
654 if err == io.EOF {
655 break
656 }
657 if err != nil {
658 return "", fmt.Errorf("failed to read tar: %w", err)
659 }
660
661 // Look for the dumber binary (inside dumber_VERSION/ directory).
662 if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") {
663 // Sanitize the path to prevent path traversal attacks.
664 _, err := sanitizeTarPath(header.Name, destDir)
665 if err != nil {
666 return "", fmt.Errorf("invalid tar entry: %w", err)
667 }
668
669 // We always extract to a fixed name regardless of archive path.
670 binaryPath = filepath.Join(destDir, "dumber")
671
672 outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm)
673 if err != nil {
674 return "", fmt.Errorf("failed to create binary file: %w", err)
675 }
676
677 // Limit copy to prevent decompression bombs (G110).
678 written, err := io.CopyN(outFile, tr, maxBinarySize)
679 if closeErr := outFile.Close(); closeErr != nil && err == nil {
680 err = closeErr
681 }
682 if err != nil && !errors.Is(err, io.EOF) {
683 _ = os.Remove(binaryPath)
684 return "", fmt.Errorf("failed to extract binary: %w", err)
685 }
686
687 // Verify minimum binary size to detect truncated/corrupt files.
688 if written < minBinarySize {
689 _ = os.Remove(binaryPath)
690 return "", fmt.Errorf("binary too small (%d bytes), expected at least %d bytes", written, minBinarySize)
691 }
692
693 log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary")
694 break
695 }
696 }
697
698 if binaryPath == "" {
699 return "", fmt.Errorf("dumber binary not found in archive")
700 }
701
702 return binaryPath, nil
703}