diff --git a/go.mod b/go.mod index 36aaad424..7bded1e46 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/rjeczalik/notify v0.9.3 + github.com/schollz/progressbar/v3 v3.19.0 github.com/sevlyar/go-daemon v0.1.6 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 @@ -20,7 +21,8 @@ require ( github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.8.4 // indirect golang.org/x/sys v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 1984fc7bf..6729a730e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -19,11 +21,19 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs= github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -34,8 +44,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/util/downloader/download.go b/util/downloader/download.go index 7a8eb9be9..c358d9abd 100644 --- a/util/downloader/download.go +++ b/util/downloader/download.go @@ -1,16 +1,18 @@ package downloader import ( + "context" + "encoding/json" "fmt" "os" "path" "path/filepath" "strings" + "time" "github.com/abiosoft/colima/config" "github.com/abiosoft/colima/environment" "github.com/abiosoft/colima/util/shautil" - "github.com/abiosoft/colima/util/terminal" ) type ( @@ -45,22 +47,18 @@ func DownloadToGuest(host hostActions, guest guestActions, r Request, filename s // Download downloads file at url and returns the location of the downloaded file. func Download(host hostActions, r Request) (string, error) { - d := downloader{ - host: host, - } + d := downloader{} if !d.hasCache(r.URL) { if err := d.downloadFile(r); err != nil { - return "", fmt.Errorf("error downloading '%s': %w", r.URL, err) + return "", err } } return CacheFilename(r.URL), nil } -type downloader struct { - host hostActions -} +type downloader struct{} // CacheFilename returns the computed filename for the url. func CacheFilename(url string) string { @@ -71,40 +69,85 @@ func (d downloader) cacheDownloadingFileName(url string) string { return CacheFilename(url) + ".downloading" } +func (d downloader) resumeInfoPath(url string) string { + return CacheFilename(url) + ".resume" +} + func (d downloader) downloadFile(r Request) (err error) { - // save to a temporary file initially before renaming to the desired file after successful download - // this prevents having a corrupt file cacheDownloadingFilename := d.cacheDownloadingFileName(r.URL) - if err := d.host.RunQuiet("mkdir", "-p", filepath.Dir(cacheDownloadingFilename)); err != nil { + + // create cache directory + cacheDir := filepath.Dir(cacheDownloadingFilename) + if err := os.MkdirAll(cacheDir, 0755); err != nil { return fmt.Errorf("error preparing cache dir: %w", err) } - // get rid of curl's initial progress bar by getting the redirect url directly. - downloadURL, err := d.host.RunOutput("curl", "-ILs", "-o", "/dev/null", "-w", "%{url_effective}", r.URL) + // check for existing partial download and resume info + var resumeInfo ResumeInfo + resumeInfoPath := d.resumeInfoPath(r.URL) + if data, err := os.ReadFile(resumeInfoPath); err == nil { + _ = json.Unmarshal(data, &resumeInfo) + } + + // get existing file size for resume + var existingSize int64 + if stat, err := os.Stat(cacheDownloadingFilename); err == nil { + existingSize = stat.Size() + } + + // create HTTP client + client := NewHTTPClient() + + // use a long timeout for large files (2 hours) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + + // get final URL (follows redirects) + finalURL, err := client.GetFinalURL(ctx, r.URL) if err != nil { - return fmt.Errorf("error retrieving redirect url: %w", err) + return fmt.Errorf("error resolving download URL '%s': %w", r.URL, err) } - // ask curl to resume previous download if possible "-C -" - if err := d.host.RunInteractive("curl", "-L", "-#", "-C", "-", "-o", cacheDownloadingFilename, downloadURL); err != nil { - return err + // download the file + result, err := client.Download(ctx, DownloadOptions{ + URL: finalURL, + DestPath: cacheDownloadingFilename, + ExpectedETag: resumeInfo.ETag, + ResumeFromByte: existingSize, + ShowProgress: true, + }) + if err != nil { + // save resume info for next attempt if we have ETag + if result != nil && result.ETag != "" { + d.saveResumeInfo(r.URL, result.ETag, existingSize) + } + return fmt.Errorf("error downloading '%s': %w", path.Base(r.URL), err) } - // clear curl progress line - terminal.ClearLine() - // validate download if sha is present - if r.SHA != nil { - if err := r.SHA.validateDownload(d.host, r.URL, cacheDownloadingFilename); err != nil { + // clean up resume info on successful download + _ = os.Remove(resumeInfoPath) + // validate download if SHA is present + if r.SHA != nil { + if err := r.SHA.validateDownload(r.URL, cacheDownloadingFilename); err != nil { // move file to allow subsequent re-download - // error discarded, would not be actioned anyways - _ = d.host.RunQuiet("mv", cacheDownloadingFilename, cacheDownloadingFilename+".invalid") - + _ = os.Rename(cacheDownloadingFilename, cacheDownloadingFilename+".invalid") return fmt.Errorf("error validating SHA sum for '%s': %w", path.Base(r.URL), err) } } - return d.host.RunQuiet("mv", cacheDownloadingFilename, CacheFilename(r.URL)) + // move completed download to final location + if err := os.Rename(cacheDownloadingFilename, CacheFilename(r.URL)); err != nil { + return fmt.Errorf("error finalizing download: %w", err) + } + + return nil +} + +func (d downloader) saveResumeInfo(url, etag string, bytesWritten int64) { + info := ResumeInfo{ETag: etag, BytesWritten: bytesWritten} + data, _ := json.Marshal(info) + _ = os.WriteFile(d.resumeInfoPath(url), data, 0644) } func (d downloader) hasCache(url string) bool { diff --git a/util/downloader/errors.go b/util/downloader/errors.go new file mode 100644 index 000000000..a94f9a6ba --- /dev/null +++ b/util/downloader/errors.go @@ -0,0 +1,121 @@ +package downloader + +import ( + "errors" + "fmt" + "net" + "net/url" + "path" + "syscall" +) + +// Sentinel errors for type checking +var ( + ErrNetworkConnection = errors.New("network connection error") + ErrHTTPStatus = errors.New("HTTP error") + ErrResumeFailed = errors.New("resume failed") + ErrSHAValidation = errors.New("SHA validation failed") +) + +// NetworkError wraps network-related errors with user-friendly messages +type NetworkError struct { + Op string // "connect", "resolve", "download" + URL string + Err error +} + +func (e *NetworkError) Error() string { + return fmt.Sprintf("%s failed for '%s': %s", e.Op, e.URL, e.friendlyMessage()) +} + +func (e *NetworkError) Unwrap() error { + return e.Err +} + +func (e *NetworkError) friendlyMessage() string { + // check for DNS resolution errors + var dnsErr *net.DNSError + if errors.As(e.Err, &dnsErr) { + return fmt.Sprintf("DNS lookup failed for host '%s'. Check your network connection or DNS settings", dnsErr.Name) + } + + // check for connection refused + var opErr *net.OpError + if errors.As(e.Err, &opErr) { + if errors.Is(opErr.Err, syscall.ECONNREFUSED) { + return "connection refused. The server may be down or unreachable" + } + if opErr.Timeout() { + return "connection timed out. Check your network connection or try again later" + } + } + + // check for URL parsing errors + var urlErr *url.Error + if errors.As(e.Err, &urlErr) { + if urlErr.Timeout() { + return "request timed out. The server may be slow or overloaded" + } + } + + return e.Err.Error() +} + +// HTTPStatusError represents HTTP error responses +type HTTPStatusError struct { + StatusCode int + Status string + URL string +} + +func (e *HTTPStatusError) Error() string { + switch e.StatusCode { + case 404: + return fmt.Sprintf("file not found at '%s'. The URL may be incorrect or the file may have been removed", e.URL) + case 403: + return fmt.Sprintf("access forbidden to '%s'. You may need authentication or the resource is restricted", e.URL) + case 401: + return fmt.Sprintf("authentication required for '%s'", e.URL) + case 500, 502, 503, 504: + return fmt.Sprintf("server error (%d) at '%s'. Try again later", e.StatusCode, e.URL) + case 416: // Range Not Satisfiable + return fmt.Sprintf("resume failed for '%s'. The server does not support the requested byte range", e.URL) + default: + return fmt.Sprintf("HTTP %d (%s) for '%s'", e.StatusCode, e.Status, e.URL) + } +} + +func (e *HTTPStatusError) Unwrap() error { + return ErrHTTPStatus +} + +// ResumeError indicates a failed resume attempt +type ResumeError struct { + Reason string + URL string +} + +func (e *ResumeError) Error() string { + return fmt.Sprintf("cannot resume download of '%s': %s. Starting fresh download", path.Base(e.URL), e.Reason) +} + +func (e *ResumeError) Unwrap() error { + return ErrResumeFailed +} + +// SHAValidationError indicates checksum mismatch +type SHAValidationError struct { + File string + Expected string + Actual string + Size int // 256 or 512 +} + +func (e *SHAValidationError) Error() string { + return fmt.Sprintf("SHA%d checksum mismatch for '%s':\n expected: %s\n actual: %s\nThe file may be corrupted or tampered with. Delete the cached file and retry", + e.Size, e.File, e.Expected, e.Actual) +} + +func (e *SHAValidationError) Unwrap() error { + return ErrSHAValidation +} diff --git a/util/downloader/http.go b/util/downloader/http.go new file mode 100644 index 000000000..6f2e9f23d --- /dev/null +++ b/util/downloader/http.go @@ -0,0 +1,302 @@ +package downloader + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/abiosoft/colima/config" + "github.com/schollz/progressbar/v3" + "golang.org/x/term" +) + +// HTTPClient encapsulates HTTP download operations +type HTTPClient struct { + client *http.Client + userAgent string +} + +// DownloadOptions configures a download operation +type DownloadOptions struct { + URL string + DestPath string + ExpectedETag string // for resume validation + ResumeFromByte int64 // byte offset to resume from + ShowProgress bool +} + +// DownloadResult contains metadata about the completed download +type DownloadResult struct { + FinalURL string // After following redirects + ETag string // For future resume validation + TotalBytes int64 + WasResumed bool +} + +// ResumeInfo stores metadata for resumable downloads +type ResumeInfo struct { + ETag string `json:"etag"` + BytesWritten int64 `json:"bytes_written"` +} + +// NewHTTPClient creates a configured HTTP client +func NewHTTPClient() *HTTPClient { + transport := &http.Transport{ + // use proxy from environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + return &HTTPClient{ + client: &http.Client{ + Transport: transport, + // checkRedirect is left default - Go follows up to 10 redirects + // and returns the final response + }, + userAgent: "colima/" + config.AppVersion().Version, + } +} + +// GetFinalURL follows redirects and returns the final URL +func (h *HTTPClient) GetFinalURL(ctx context.Context, rawURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil) + if err != nil { + return "", fmt.Errorf("invalid URL '%s': %w", rawURL, err) + } + req.Header.Set("User-Agent", h.userAgent) + + resp, err := h.client.Do(req) + if err != nil { + return "", &NetworkError{Op: "resolve redirect", URL: rawURL, Err: err} + } + defer func() { _ = resp.Body.Close() }() + + // check for HTTP errors + if resp.StatusCode >= 400 { + return "", &HTTPStatusError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + URL: rawURL, + } + } + + // resp.Request.URL contains the final URL after redirects + return resp.Request.URL.String(), nil +} + +// Download performs a file download with optional resume support +func (h *HTTPClient) Download(ctx context.Context, opts DownloadOptions) (*DownloadResult, error) { + result := &DownloadResult{} + + // open destination file for writing (or appending if resuming) + var file *os.File + var existingSize int64 + var err error + + if opts.ResumeFromByte > 0 { + file, err = os.OpenFile(opts.DestPath, os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + // can't resume, start fresh + opts.ResumeFromByte = 0 + opts.ExpectedETag = "" + } else { + existingSize = opts.ResumeFromByte + } + } + + if file == nil { + file, err = os.Create(opts.DestPath) + if err != nil { + return nil, fmt.Errorf("cannot create file '%s': %w", opts.DestPath, err) + } + } + defer func() { _ = file.Close() }() + + // build request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil) + if err != nil { + return nil, fmt.Errorf("invalid URL '%s': %w", opts.URL, err) + } + req.Header.Set("User-Agent", h.userAgent) + + // add Range header for resume + if existingSize > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existingSize)) + // add If-Range with ETag if available for safe resume + if opts.ExpectedETag != "" { + req.Header.Set("If-Range", opts.ExpectedETag) + } + } + + // execute request + resp, err := h.client.Do(req) + if err != nil { + return nil, &NetworkError{Op: "download", URL: opts.URL, Err: err} + } + defer func() { _ = resp.Body.Close() }() + + // store final URL after redirects + result.FinalURL = resp.Request.URL.String() + result.ETag = resp.Header.Get("ETag") + + // handle response status + switch resp.StatusCode { + case http.StatusOK: // 200 - Full content (resume not supported or If-Range failed) + if existingSize > 0 { + // server sent full content, need to truncate and start over + if err := file.Truncate(0); err != nil { + return nil, fmt.Errorf("cannot truncate file for fresh download: %w", err) + } + if _, err := file.Seek(0, 0); err != nil { + return nil, fmt.Errorf("cannot seek to start of file: %w", err) + } + existingSize = 0 + } + result.TotalBytes = resp.ContentLength + + case http.StatusPartialContent: // 206 - Resume successful + result.WasResumed = true + // Content-Range: bytes 21010-47021/47022 + contentRange := resp.Header.Get("Content-Range") + if totalSize := parseContentRangeTotal(contentRange); totalSize > 0 { + result.TotalBytes = totalSize + } else { + result.TotalBytes = existingSize + resp.ContentLength + } + + case http.StatusRequestedRangeNotSatisfiable: // 416 + // file is likely complete or server doesn't support range + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + URL: opts.URL, + } + + default: + if resp.StatusCode >= 400 { + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + URL: opts.URL, + } + } + } + + // set up progress bar + var writer io.Writer = file + var bar *progressbar.ProgressBar + if opts.ShowProgress && isTerminal() { + bar = h.createProgressBar(result.TotalBytes, existingSize) + writer = io.MultiWriter(file, bar) + } + + // stream response body to file + written, err := io.Copy(writer, resp.Body) + if err != nil { + return result, &NetworkError{Op: "download", URL: opts.URL, Err: err} + } + + // finish progress bar + if bar != nil { + _ = bar.Finish() + } + + result.TotalBytes = existingSize + written + return result, nil +} + +// Fetch downloads content from a URL and returns it as bytes (for small files like SHA checksums) +func (h *HTTPClient) Fetch(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("invalid URL '%s': %w", url, err) + } + req.Header.Set("User-Agent", h.userAgent) + + resp, err := h.client.Do(req) + if err != nil { + return nil, &NetworkError{Op: "fetch", URL: url, Err: err} + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + URL: url, + } + } + + // limit read to 1MB for safety (SHA files should be tiny) + return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) +} + +// createProgressBar creates a progress bar for download visualization +func (h *HTTPClient) createProgressBar(totalBytes, startOffset int64) *progressbar.ProgressBar { + opts := []progressbar.Option{ + progressbar.OptionSetDescription(" "), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowBytes(true), + progressbar.OptionSetWidth(30), + progressbar.OptionThrottle(100 * time.Millisecond), + progressbar.OptionClearOnFinish(), + progressbar.OptionSetPredictTime(true), + progressbar.OptionSetRenderBlankState(true), + progressbar.OptionSetTheme(progressbar.Theme{ + Saucer: "=", + SaucerHead: ">", + SaucerPadding: " ", + BarStart: "[", + BarEnd: "]", + }), + } + + // if total size is unknown, use a spinner + if totalBytes <= 0 { + opts = append(opts, progressbar.OptionSpinnerType(11)) + totalBytes = -1 + } + + bar := progressbar.NewOptions64(totalBytes, opts...) + + // if resuming, set initial progress + if startOffset > 0 { + _ = bar.Set64(startOffset) + } + + return bar +} + +// parseContentRangeTotal extracts total size from Content-Range header +// Format: "bytes 21010-47021/47022" or "bytes 21010-47021/*" +func parseContentRangeTotal(header string) int64 { + if header == "" { + return -1 + } + parts := strings.Split(header, "/") + if len(parts) != 2 || parts[1] == "*" { + return -1 + } + total, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return -1 + } + return total +} + +// isTerminal returns true if stderr is a terminal +func isTerminal() bool { + return term.IsTerminal(int(os.Stderr.Fd())) +} diff --git a/util/downloader/sha.go b/util/downloader/sha.go index 0ce970fbb..7d1a90b91 100644 --- a/util/downloader/sha.go +++ b/util/downloader/sha.go @@ -1,12 +1,18 @@ package downloader import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "crypto/sha512" "fmt" + "hash" + "io" + "os" "path/filepath" - "strconv" "strings" - - "github.com/abiosoft/colima/util" + "time" ) // SHA is the shasum of a file. @@ -17,28 +23,54 @@ type SHA struct { } // ValidateFile validates the SHA of the file. +// The host parameter is kept for API compatibility but is not used. func (s SHA) ValidateFile(host hostActions, file string) error { - dir, filename := filepath.Split(file) - digest := strings.TrimPrefix(s.Digest, fmt.Sprintf("sha%d:", s.Size)) - shasumBinary := "shasum" - if util.MacOS() { - shasumBinary = "/usr/bin/shasum" + return s.validateFile(file) +} + +// validateFile performs SHA validation using pure Go crypto. +func (s SHA) validateFile(file string) error { + // open the file + f, err := os.Open(file) + if err != nil { + return fmt.Errorf("cannot open file for validation: %w", err) + } + defer func() { _ = f.Close() }() + + // select hash algorithm + var h hash.Hash + switch s.Size { + case 256: + h = sha256.New() + case 512: + h = sha512.New() + default: + return fmt.Errorf("unsupported SHA size: %d (must be 256 or 512)", s.Size) + } + + // compute hash + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("error reading file for SHA validation: %w", err) } - script := strings.NewReplacer( - "{dir}", dir, - "{digest}", digest, - "{size}", strconv.Itoa(s.Size), - "{filename}", filename, - "{shasum_bin}", shasumBinary, - ).Replace( - `cd {dir} && echo "{digest} {filename}" | {shasum_bin} -a {size} --check --status`, - ) - - return host.Run("sh", "-c", script) + // compare + computed := fmt.Sprintf("%x", h.Sum(nil)) + expected := strings.TrimPrefix(s.Digest, fmt.Sprintf("sha%d:", s.Size)) + expected = strings.ToLower(strings.TrimSpace(expected)) + + if computed != expected { + return &SHAValidationError{ + File: filepath.Base(file), + Expected: expected, + Actual: computed, + Size: s.Size, + } + } + + return nil } -func (s SHA) validateDownload(host hostActions, url string, filename string) error { +func (s SHA) validateDownload(url string, filename string) error { if s.URL == "" && s.Digest == "" { return fmt.Errorf("error validating SHA: one of Digest or URL must be set") } @@ -46,34 +78,67 @@ func (s SHA) validateDownload(host hostActions, url string, filename string) err // fetch digest from URL if empty if s.Digest == "" { // retrieve the filename from the download url. - filename := func() string { - if url == "" { - return "" - } + targetFilename := "" + if url != "" { split := strings.Split(url, "/") - return split[len(split)-1] - }() + targetFilename = split[len(split)-1] + } - digest, err := fetchSHAFromURL(host, s.URL, filename) + digest, err := fetchSHAFromURL(s.URL, targetFilename) if err != nil { return err } s.Digest = digest } - return s.ValidateFile(host, filename) + return s.validateFile(filename) } -func fetchSHAFromURL(host hostActions, url, filename string) (string, error) { - script := strings.NewReplacer( - "{url}", url, - "{filename}", filename, - ).Replace( - "curl -sL {url} | grep ' {filename}$' | awk -F' ' '{print $1}'", - ) - sha, err := host.RunOutput("sh", "-c", script) +// fetchSHAFromURL fetches SHA checksum file and extracts digest for the target file +func fetchSHAFromURL(shaURL, targetFilename string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := NewHTTPClient() + + // fetch SHA file content + data, err := client.Fetch(ctx, shaURL) + if err != nil { + return "", fmt.Errorf("error downloading SHA file from '%s': %w", shaURL, err) + } + + // parse SHA file to find the matching entry + digest, err := parseSHAContent(data, targetFilename) if err != nil { - return "", fmt.Errorf("error retrieving sha from url '%s': %w", url, err) + return "", fmt.Errorf("error parsing SHA file from '%s': %w", shaURL, err) + } + + return digest, nil +} + +// parseSHAContent reads SHA checksum content and extracts the digest for the target filename. +// Supports formats: +// - GNU coreutils: " " (two spaces) +// - BSD/binary mode: " *" (space + asterisk) +func parseSHAContent(data []byte, targetFilename string) (string, error) { + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := scanner.Text() + // format: " " (two spaces) or " *" (binary mode) + parts := strings.Fields(line) + if len(parts) >= 2 { + hash := parts[0] + filename := strings.TrimPrefix(parts[len(parts)-1], "*") + + if filename == targetFilename || strings.HasSuffix(filename, "/"+targetFilename) { + return hash, nil + } + } } - return strings.TrimSpace(sha), nil + + if err := scanner.Err(); err != nil { + return "", err + } + + return "", fmt.Errorf("no SHA entry found for '%s' in checksum file", targetFilename) }