215 lines
5.6 KiB
Go
215 lines
5.6 KiB
Go
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
|
||
}
|