Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks.

This commit is contained in:
M4
2026-07-29 21:43:41 +03:00
parent 64c097d1e7
commit 54b5b87990
26 changed files with 534 additions and 619 deletions
+214
View File
@@ -0,0 +1,214 @@
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
}
+12
View File
@@ -0,0 +1,12 @@
package coredl
import "testing"
func TestParseDigestAndVerify(t *testing.T) {
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
t.Fatalf("parse: %q", got)
}
if parseDigestSHA256("md5:x") != "" {
t.Fatal("expected empty for non-sha256")
}
}