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 }