Files
navi/internal/coredl/download.go
T

215 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package coredl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
type ReleaseAsset struct {
Name string
BrowserDownloadURL string
SHA256 string // lowercase hex, empty if unknown
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
// FetchLatestAssets loads assets for a repo's latest GitHub release.
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
if err != nil {
return "", nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", nil, err
}
out := make([]ReleaseAsset, 0, len(rel.Assets))
for _, a := range rel.Assets {
out = append(out, ReleaseAsset{
Name: a.Name,
BrowserDownloadURL: a.BrowserDownloadURL,
SHA256: parseDigestSHA256(a.Digest),
})
}
return rel.TagName, out, nil
}
func parseDigestSHA256(digest string) string {
digest = strings.TrimSpace(strings.ToLower(digest))
if digest == "" {
return ""
}
if strings.HasPrefix(digest, "sha256:") {
return strings.TrimPrefix(digest, "sha256:")
}
return ""
}
// DownloadFile writes url to path (size-capped).
func DownloadFile(path, url string, maxBytes int64) error {
if maxBytes <= 0 {
maxBytes = 120 << 20
}
client := &http.Client{Timeout: 15 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
return err
}
// FileSHA256 returns lowercase hex digest of path.
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
func VerifySHA256(path, want string) error {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
}
got, err := FileSHA256(path)
if err != nil {
return err
}
if got != want {
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// RequireSHA returns sha or an error if the asset has none.
func RequireSHA(a ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
}
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
for _, name := range companions {
for _, o := range all {
if !strings.EqualFold(o.Name, name) {
continue
}
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
if err != nil {
return "", err
}
if sum != "" {
return sum, nil
}
}
}
return RequireSHA(a)
}
func fetchChecksumFile(url, assetName string) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum download: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
text := string(body)
lowerName := strings.ToLower(assetName)
lines := strings.Split(text, "\n")
var shaLine string
for _, line := range lines {
l := strings.TrimSpace(strings.ToLower(line))
if strings.HasPrefix(l, "sha256:") {
shaLine = strings.TrimSpace(line[len("sha256:"):])
// keep scanning — prefer line that also mentions the asset
if strings.Contains(l, lowerName) {
return strings.ToLower(strings.Fields(shaLine)[0]), nil
}
}
fields := strings.Fields(line)
if len(fields) >= 2 {
sum := strings.ToLower(fields[0])
file := strings.TrimPrefix(fields[len(fields)-1], "*")
if len(sum) == 64 && strings.EqualFold(file, assetName) {
return sum, nil
}
}
}
if shaLine != "" {
parts := strings.Fields(shaLine)
if len(parts) > 0 && len(parts[0]) == 64 {
return strings.ToLower(parts[0]), nil
}
}
return "", nil
}
// DownloadAndVerify downloads url to path and checks sha256.
func DownloadAndVerify(path, url, wantSHA256 string) error {
if err := DownloadFile(path, url, 0); err != nil {
return err
}
if err := VerifySHA256(path, wantSHA256); err != nil {
_ = os.Remove(path)
return err
}
return nil
}