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