v0.26.1 75cd777

github.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close