Skip to content

Commit 7d2fb0d

Browse files
authored
refactor: extract shared func CLI download with checksum verification (#132)
1 parent 73f5614 commit 7d2fb0d

4 files changed

Lines changed: 428 additions & 156 deletions

File tree

internal/funccli/download.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package funccli
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
goruntime "runtime"
13+
"strings"
14+
"time"
15+
)
16+
17+
// DownloadOptions configures the binary download behavior.
18+
type DownloadOptions struct {
19+
// HTTPClient is the HTTP client to use for downloading.
20+
// If nil, a default client with a 30-second timeout is used.
21+
HTTPClient *http.Client
22+
}
23+
24+
func (o *DownloadOptions) httpClient() *http.Client {
25+
if o != nil && o.HTTPClient != nil {
26+
return o.HTTPClient
27+
}
28+
return &http.Client{Timeout: 30 * time.Second}
29+
}
30+
31+
// AssetName returns the func CLI asset name for the current platform
32+
// (e.g. "func_linux_amd64").
33+
func AssetName() string {
34+
return fmt.Sprintf("func_%s_%s", goruntime.GOOS, goruntime.GOARCH)
35+
}
36+
37+
// DownloadAndInstall downloads a func CLI binary from binaryURL, verifies its
38+
// SHA256 checksum against the checksums.txt at checksumURL, and atomically
39+
// installs it to installPath. The parent directory of installPath must exist.
40+
func DownloadAndInstall(ctx context.Context, binaryURL, checksumURL, installPath string, opts *DownloadOptions) error {
41+
client := opts.httpClient()
42+
assetName := filepath.Base(binaryURL)
43+
44+
tmpFile := installPath + ".tmp"
45+
if err := downloadToFile(ctx, client, binaryURL, tmpFile); err != nil {
46+
return fmt.Errorf("failed to download binary: %w", err)
47+
}
48+
49+
if err := verifyFileChecksum(ctx, client, tmpFile, assetName, checksumURL); err != nil {
50+
_ = os.Remove(tmpFile)
51+
return fmt.Errorf("checksum verification failed: %w", err)
52+
}
53+
54+
if err := os.Chmod(tmpFile, 0o755); err != nil {
55+
_ = os.Remove(tmpFile)
56+
return fmt.Errorf("failed to make binary executable: %w", err)
57+
}
58+
59+
if err := os.Rename(tmpFile, installPath); err != nil {
60+
_ = os.Remove(tmpFile)
61+
return fmt.Errorf("failed to install binary: %w", err)
62+
}
63+
64+
return nil
65+
}
66+
67+
func downloadToFile(ctx context.Context, client *http.Client, url, path string) error {
68+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
69+
if err != nil {
70+
return err
71+
}
72+
73+
resp, err := client.Do(req)
74+
if err != nil {
75+
return err
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode != http.StatusOK {
80+
return fmt.Errorf("download failed with status %d", resp.StatusCode)
81+
}
82+
83+
out, err := os.Create(path)
84+
if err != nil {
85+
return err
86+
}
87+
defer out.Close()
88+
89+
_, err = io.Copy(out, resp.Body)
90+
return err
91+
}
92+
93+
func verifyFileChecksum(ctx context.Context, client *http.Client, filePath, assetName, checksumURL string) error {
94+
expectedHash, err := fetchExpectedChecksum(ctx, client, assetName, checksumURL)
95+
if err != nil {
96+
return err
97+
}
98+
99+
f, err := os.Open(filePath)
100+
if err != nil {
101+
return fmt.Errorf("failed to open file for checksum: %w", err)
102+
}
103+
defer f.Close()
104+
105+
h := sha256.New()
106+
if _, err := io.Copy(h, f); err != nil {
107+
return fmt.Errorf("failed to compute checksum: %w", err)
108+
}
109+
110+
actualHash := hex.EncodeToString(h.Sum(nil))
111+
if actualHash != expectedHash {
112+
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedHash, actualHash)
113+
}
114+
115+
return nil
116+
}
117+
118+
func fetchExpectedChecksum(ctx context.Context, client *http.Client, assetName, checksumURL string) (string, error) {
119+
req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil)
120+
if err != nil {
121+
return "", err
122+
}
123+
124+
resp, err := client.Do(req)
125+
if err != nil {
126+
return "", fmt.Errorf("failed to download checksums: %w", err)
127+
}
128+
defer resp.Body.Close()
129+
130+
if resp.StatusCode != http.StatusOK {
131+
return "", fmt.Errorf("checksums download failed with status %d", resp.StatusCode)
132+
}
133+
134+
body, err := io.ReadAll(resp.Body)
135+
if err != nil {
136+
return "", fmt.Errorf("failed to read checksums: %w", err)
137+
}
138+
139+
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
140+
parts := strings.Fields(line)
141+
if len(parts) == 2 && parts[1] == assetName {
142+
return parts[0], nil
143+
}
144+
}
145+
146+
return "", fmt.Errorf("no checksum found for asset %s", assetName)
147+
}

0 commit comments

Comments
 (0)