Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
go.bug.st/cleanup v1.0.0
go.bug.st/downloader/v2 v2.2.0
go.bug.st/f v0.4.0
go.bug.st/relaxed-semver v0.15.0
google.golang.org/grpc v1.77.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
Expand All @@ -183,6 +184,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.bug.st/cleanup v1.0.0 h1:XVj1HZxkBXeq3gMT7ijWUpHyIC1j8XAoNSyQ06CskgA=
go.bug.st/cleanup v1.0.0/go.mod h1:EqVmTg2IBk4znLbPD28xne3abjsJftMdqqJEjhn70bk=
go.bug.st/downloader/v2 v2.2.0 h1:Y0jSuDISNhrzePkrAWqz9xUC3xol9hqZo/+tz1D4EqY=
go.bug.st/downloader/v2 v2.2.0/go.mod h1:VZW2V1iGKV8rJL2ZEGIDzzBeKowYv34AedJz13RzVII=
go.bug.st/f v0.4.0 h1:Vstqb950nMA+PhAlRxUw8QL1ntHy/gXHNyyzjkQLJ10=
go.bug.st/f v0.4.0/go.mod h1:bMo23205ll7UW63KwO1ut5RdlJ9JK8RyEEr88CmOF5Y=
go.bug.st/relaxed-semver v0.15.0 h1:w37+SYQPxF53RQO7QZZuPIMaPouOifdaP0B1ktst2nA=
Expand Down
54 changes: 9 additions & 45 deletions internal/updater/download_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,14 @@
package updater

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"

"github.com/arduino/go-paths-helper"
"github.com/codeclysm/extract/v4"
"github.com/schollz/progressbar/v3"
"go.bug.st/downloader/v2"

"github.com/arduino/arduino-flasher-cli/cmd/feedback"
"github.com/arduino/arduino-flasher-cli/cmd/i18n"
Expand Down Expand Up @@ -65,58 +62,25 @@ func DownloadAndExtract(ctx context.Context, targetVersion string, temp *paths.P
}

func DownloadImage(ctx context.Context, targetVersion string, downloadPath *paths.Path) (*paths.Path, string, error) {
var err error

client := NewClient()
manifest, err := client.GetInfoManifest(ctx)
if err != nil {
return nil, "", err
}

var rel *Release
if targetVersion == "latest" || targetVersion == manifest.Latest.Version {
rel = &manifest.Latest
} else {
for _, r := range manifest.Releases {
if targetVersion == r.Version {
rel = &r
break
}
}
}

if rel == nil {
return nil, "", fmt.Errorf("could not find Debian image %s", targetVersion)
}

download, size, err := client.FetchZip(ctx, rel.Url)
rel, err := client.GetReleaseByVersion(ctx, targetVersion)
if err != nil {
return nil, "", fmt.Errorf("could not fetch Debian image: %w", err)
return nil, "", fmt.Errorf("could not get release info: %w", err)
}
defer download.Close()

tmpZip := downloadPath.Join("arduino-unoq-debian-image-" + rel.Version + ".tar.zst")
tmpZipFile, err := tmpZip.Create()
if err != nil {
return nil, "", err
}
defer tmpZipFile.Close()

// Download and keep track of the progress
bar := progressbar.DefaultBytes(
size,
0,
i18n.Tr("Downloading Debian image version %s", rel.Version),
)
checksum := sha256.New()
if _, err := io.Copy(io.MultiWriter(checksum, tmpZipFile, bar), download); err != nil {
return nil, "", err
callback := func(current, total int64) {
bar.AddMax64(total)
_ = bar.Set64(current)
}

// Check the hash
if sha256Byte, err := hex.DecodeString(rel.Sha256); err != nil {
return nil, "", fmt.Errorf("could not convert sha256 from hex to bytes: %w", err)
} else if s := checksum.Sum(nil); !bytes.Equal(s, sha256Byte) {
return nil, "", fmt.Errorf("bad hash: %x (expected %x)", s, sha256Byte)
if err := client.DownloadFile(ctx, tmpZip, rel, callback, downloader.Config{}); err != nil {
return nil, "", fmt.Errorf("could not download Debian image: %w", err)
}

return tmpZip, rel.Version, nil
Expand Down
98 changes: 79 additions & 19 deletions internal/updater/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package updater

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
Expand All @@ -24,8 +25,14 @@ import (
"io"
"net/http"
"net/url"
"time"

"github.com/arduino/go-paths-helper"
"github.com/shirou/gopsutil/v4/disk"
"go.bug.st/downloader/v2"
"go.bug.st/f"

"github.com/arduino/arduino-flasher-cli/cmd/i18n"
)

var baseURL = f.Must(url.Parse("https://downloads.arduino.cc"))
Expand All @@ -34,15 +41,10 @@ const pathRelease = "debian-im/Stable"

// Client holds the base URL, command name, allows custom HTTP client, and optional headers.
type Client struct {
HTTPClient HTTPDoer
HTTPClient *http.Client
Headers map[string]string // Optional headers to add to each request
}

// HTTPDoer is an interface for http.Client or mocks.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}

// Option is a functional option for configuring Client.
type Option func(*Client)

Expand All @@ -54,7 +56,7 @@ func WithHeaders(headers map[string]string) Option {
}

// WithHTTPClient sets a custom HTTP client for the Client.
func WithHTTPClient(client HTTPDoer) Option {
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.HTTPClient = client
}
Expand Down Expand Up @@ -109,21 +111,79 @@ func (c *Client) GetInfoManifest(ctx context.Context) (Manifest, error) {
return res, nil
}

// FetchZip fetches the Debian image archive.
func (c *Client) FetchZip(ctx context.Context, zipURL string) (io.ReadCloser, int64, error) {
req, err := http.NewRequestWithContext(ctx, "GET", zipURL, nil)
func (c *Client) GetReleaseByVersion(ctx context.Context, version string) (Release, error) {
manifest, err := c.GetInfoManifest(ctx)
if err != nil {
return nil, 0, fmt.Errorf("failed to create request: %w", err)
return Release{}, err
}
c.addHeaders(req)
// #nosec G107 -- zipURL is constructed from trusted config and parameters
resp, err := c.HTTPClient.Do(req)

if version == "latest" || version == manifest.Latest.Version {
return manifest.Latest, nil
} else {
for _, r := range manifest.Releases {
if version == r.Version {
return r, nil
}
}
}

return Release{}, fmt.Errorf("could not find Debian image %s", version)
}

type downloadCallback func(current, total int64)

// DownloadFile downloads a file from a URL into the specified path. An optional config and options may be passed (or nil to use the defaults).
// A DownloadProgressCB callback function must be passed to monitor download progress.
// If a not empty queryParameter is passed, it is appended to the URL for analysis purposes.
func (c *Client) DownloadFile(ctx context.Context, path *paths.Path, rel Release, cb downloadCallback, config downloader.Config, options ...downloader.DownloadOptions) (returnedError error) {

// Check if there is enough free disk space before downloading and extracting an image
dk, err := disk.Usage(path.String())
if err != nil {
return nil, 0, fmt.Errorf("failed to GET zip: %w", err)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, 0, fmt.Errorf("bad http status from %s: %v", zipURL, resp.Status)
// TODO: improve disk space check with Content-Length header
if dk.Free/GiB < DownloadDiskSpace {
return fmt.Errorf("download and extraction requires up to %d GiB of free space", DownloadDiskSpace)
}
return resp.Body, resp.ContentLength, nil

config.HttpClient = *c.HTTPClient
// TODO: add headers to downloader's http client
d, err := downloader.DownloadWithConfigAndContext(ctx, path.String(), rel.Url, config, options...)
if err != nil {
return err
}

err = d.RunAndPoll(func(downloaded int64) {
cb(downloaded, d.Size())
}, 250*time.Millisecond)
if err != nil {
return err
}

// The URL is not reachable for some reason
if d.Resp.StatusCode >= 400 && d.Resp.StatusCode <= 599 {
msg := i18n.Tr("Server responded with: %s", d.Resp.Status)
return fmt.Errorf("%s", msg)
}

// Check the hash
checksum := sha256.New()
tmpZipFile, err := path.Open()
if err != nil {
return fmt.Errorf("could not open archive: %w", err)
}
defer tmpZipFile.Close()

_, err = io.Copy(checksum, tmpZipFile)
if err != nil {
return err
}
if sha256Byte, err := hex.DecodeString(rel.Sha256); err != nil {
return fmt.Errorf("could not convert sha256 from hex to bytes: %w", err)
} else if s := checksum.Sum(nil); !bytes.Equal(s, sha256Byte) {
return fmt.Errorf("bad hash: %x (expected %x)", s, sha256Byte)
}

return nil
}
Loading