Files

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
}