mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
223 lines
7.9 KiB
Go
223 lines
7.9 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/transport"
|
|
)
|
|
|
|
const releasesURL = "https://api.github.com/repos/" + Repository + "/releases?per_page=100"
|
|
|
|
var filenamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.~+-]{0,199}$`)
|
|
|
|
// Source has one origin: the official repository's immutable release assets.
|
|
// The only additional hosts are GitHub's asset redirect destinations. Proxies
|
|
// and caller-provided credentials are deliberately not inherited.
|
|
type Source struct {
|
|
client *http.Client
|
|
}
|
|
|
|
func NewSource() *Source {
|
|
return &Source{client: transport.NewReleaseClient(checkRedirect)}
|
|
}
|
|
|
|
func (s *Source) Close() { s.client.CloseIdleConnections() }
|
|
|
|
func checkRedirect(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 || request.URL.Scheme != "https" || request.URL.User != nil || request.URL.Fragment != "" {
|
|
return domain.Errorf(domain.CodeTransportFailure, "更新下载重定向无效")
|
|
}
|
|
switch request.URL.Host {
|
|
case "release-assets.githubusercontent.com", "objects.githubusercontent.com":
|
|
return nil
|
|
case "github.com":
|
|
if releaseAssetURL(request.URL.String()) {
|
|
return nil
|
|
}
|
|
}
|
|
return domain.Errorf(domain.CodeTransportFailure, "更新下载重定向不在允许范围")
|
|
}
|
|
|
|
func releaseAssetURL(raw string) bool {
|
|
u, err := url.Parse(raw)
|
|
prefix := "/" + Repository + "/releases/download/"
|
|
if err != nil || u.Scheme != "https" || u.Host != "github.com" || u.User != nil ||
|
|
u.RawPath != "" || u.RawQuery != "" || u.Fragment != "" || !strings.HasPrefix(u.Path, prefix) {
|
|
return false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(u.Path, prefix), "/")
|
|
if len(parts) != 2 || !filenamePattern.MatchString(parts[1]) {
|
|
return false
|
|
}
|
|
version, err := ParseVersion(parts[0])
|
|
return err == nil && version.Major >= 2
|
|
}
|
|
|
|
func (s *Source) open(ctx context.Context, raw string) (*http.Response, error) {
|
|
if raw != releasesURL && !releaseAssetURL(raw) {
|
|
return nil, domain.Errorf(domain.CodePackageIncompatible, "更新来源不是官方发布地址")
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
|
if err != nil {
|
|
return nil, domain.Errorf(domain.CodeInvalidArgument, "更新地址无效")
|
|
}
|
|
request.Header.Set("User-Agent", "smart-srun/2.0")
|
|
request.Header.Set("Accept-Encoding", "identity")
|
|
response, err := s.client.Do(request)
|
|
if err != nil {
|
|
return nil, releaseRequestError(ctx, err)
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
response.Body.Close()
|
|
if response.StatusCode == http.StatusNotFound {
|
|
return nil, domain.Errorf(domain.CodeNotFound, "该版本尚未提供 Go 发布清单或安装包")
|
|
}
|
|
return nil, domain.Errorf(domain.CodeTransportFailure, "更新服务器返回 HTTP %d", response.StatusCode)
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *Source) read(ctx context.Context, raw string, limit int64) ([]byte, error) {
|
|
response, err := s.open(ctx, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer response.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
|
if err != nil {
|
|
return nil, releaseRequestError(ctx, err)
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return nil, domain.Errorf(domain.CodePackageIncompatible, "发布元数据不完整或超出大小上限")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func releaseRequestError(ctx context.Context, err error) error {
|
|
code := transport.RequestErrorCode(ctx, err)
|
|
message := "无法从官方来源下载更新,请检查 GitHub 连通性与本机代理规则"
|
|
switch code {
|
|
case domain.CodeDNSFailure:
|
|
message = "无法解析官方更新来源,请检查 DNS 与本机代理规则"
|
|
case domain.CodeTLSFailure:
|
|
message = "官方更新来源 TLS 校验失败,请检查系统时间和证书"
|
|
case domain.CodeDeadlineExceeded:
|
|
message = "连接官方更新来源超时,请检查网络与本机代理规则"
|
|
case domain.CodeCancelled:
|
|
message = "更新检查已取消"
|
|
}
|
|
return domain.Errorf(code, "%s", message).Wrap(err)
|
|
}
|
|
|
|
// Candidates returns at most ten newer releases, in numerical version order.
|
|
// Release metadata is an index, not authority for arbitrary download URLs.
|
|
func (s *Source) Candidates(ctx context.Context, current Version, channel string) ([]Version, error) {
|
|
if channel == "" {
|
|
channel = current.Channel()
|
|
}
|
|
if channel != "stable" && channel != "rc" {
|
|
return nil, domain.Errorf(domain.CodeInvalidArgument, "更新通道无效")
|
|
}
|
|
data, err := s.read(ctx, releasesURL, 4<<20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var releases []struct {
|
|
Tag string `json:"tag_name"`
|
|
Draft bool `json:"draft"`
|
|
Prerelease bool `json:"prerelease"`
|
|
}
|
|
if err := json.Unmarshal(data, &releases); err != nil || releases == nil || len(releases) > 100 {
|
|
return nil, domain.Errorf(domain.CodePackageIncompatible, "发布索引无效")
|
|
}
|
|
seen := make(map[Version]bool)
|
|
var versions []Version
|
|
for _, release := range releases {
|
|
version, err := ParseVersion(release.Tag)
|
|
if err != nil || release.Draft || version.Major != current.Major || version.Major < 2 || version.Compare(current) <= 0 ||
|
|
(version.RC == 0 && release.Prerelease) || (channel == "stable" && version.RC != 0) || seen[version] {
|
|
continue
|
|
}
|
|
// GitHub's editable prerelease flag is not the version's channel.
|
|
// RC tags remain RC even if published without that flag. The official
|
|
// manifest must still agree with the tag before a plan can be built.
|
|
seen[version] = true
|
|
versions = append(versions, version)
|
|
}
|
|
slices.SortFunc(versions, func(a, b Version) int { return b.Compare(a) })
|
|
if len(versions) > 10 {
|
|
versions = versions[:10]
|
|
}
|
|
return versions, nil
|
|
}
|
|
|
|
func (s *Source) Manifest(ctx context.Context, version Version) (Manifest, error) {
|
|
raw := "https://github.com/" + Repository + "/releases/download/" + version.String() + "/release-manifest.json"
|
|
data, err := s.read(ctx, raw, MaxManifestBytes)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
manifest, err := ParseManifest(data)
|
|
if err != nil || manifest.Release != version.String() {
|
|
return Manifest{}, invalidManifest()
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
// Download writes a new private file and retains it only after the exact byte
|
|
// count, SHA256 and fsync succeed. It never extracts an archive or overwrites a
|
|
// caller's existing file. Package metadata/signatures are checked afterwards.
|
|
func (s *Source) Download(ctx context.Context, asset Asset, destination string) (resultErr error) {
|
|
if asset.Bytes <= 0 || asset.Bytes > MaxAssetBytes || !sha256Pattern.MatchString(asset.SHA256) || !releaseAssetURL(asset.URL) {
|
|
return invalidManifest()
|
|
}
|
|
response, err := s.open(ctx, asset.URL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.ContentLength >= 0 && response.ContentLength != asset.Bytes {
|
|
return domain.Errorf(domain.CodeChecksumMismatch, "安装包长度与发布清单不一致")
|
|
}
|
|
file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return domain.Errorf(domain.CodeInternal, "无法创建更新下载文件").Wrap(err)
|
|
}
|
|
defer func() {
|
|
file.Close()
|
|
if resultErr != nil {
|
|
os.Remove(destination)
|
|
}
|
|
}()
|
|
hash := sha256.New()
|
|
count, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(response.Body, asset.Bytes+1))
|
|
if err != nil {
|
|
if _, ok := errors.AsType[*os.PathError](err); ok {
|
|
return domain.Errorf(domain.CodeInternal, "无法写入更新下载文件").Wrap(err)
|
|
}
|
|
return releaseRequestError(ctx, err)
|
|
}
|
|
if count != asset.Bytes || hex.EncodeToString(hash.Sum(nil)) != asset.SHA256 {
|
|
return domain.Errorf(domain.CodeChecksumMismatch, "安装包大小或 SHA256 校验失败")
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return domain.Errorf(domain.CodeInternal, "无法保存更新下载文件").Wrap(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return domain.Errorf(domain.CodeInternal, "无法关闭更新下载文件").Wrap(err)
|
|
}
|
|
return nil
|
|
}
|