v0.23.2 bfd5eb4

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/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}

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