This commit is contained in:
ryan
2026-06-20 13:27:18 +08:00
parent cdac1f8a45
commit 6975a6c290
25 changed files with 1081 additions and 587 deletions
+52 -21
View File
@@ -70,6 +70,7 @@ type githubRelease struct {
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
}
// CheckAndUpdate checks for a newer release on GitHub and performs an update if available.
@@ -99,29 +100,13 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options Updat
slog.Info(s.logLabel+" update available", "from", localVersion, "to", remoteVersion)
assetName := s.assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
checksumAssetName := assetName + ".sha256"
var downloadURL string
var checksumURL string
for _, asset := range release.Assets {
switch asset.Name {
case assetName:
downloadURL = asset.BrowserDownloadURL
case checksumAssetName:
checksumURL = asset.BrowserDownloadURL
}
}
if downloadURL == "" {
s.lastCheckKey = checkKey
return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
}
if checksumURL == "" {
return fmt.Errorf("no matching checksum asset %q in release %s", checksumAssetName, release.TagName)
}
expectedChecksum, err := s.downloadChecksum(ctx, checksumURL, assetName)
downloadURL, expectedChecksum, err := s.resolveReleaseAsset(ctx, release, assetName)
if err != nil {
return fmt.Errorf("download checksum: %w", err)
if downloadURL == "" {
s.lastCheckKey = checkKey
}
return err
}
execPath, err := os.Executable()
@@ -223,6 +208,52 @@ func decodeRelease(reader io.Reader) (*githubRelease, error) {
return &release, nil
}
func (s *Service) resolveReleaseAsset(ctx context.Context, release *githubRelease, assetName string) (downloadURL string, expectedChecksum string, err error) {
checksumAssetName := assetName + ".sha256"
var checksumURL string
for _, asset := range release.Assets {
switch asset.Name {
case assetName:
downloadURL = asset.BrowserDownloadURL
expectedChecksum = normalizeGitHubDigest(asset.Digest)
case checksumAssetName:
checksumURL = asset.BrowserDownloadURL
}
}
if downloadURL == "" {
return "", "", fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
}
if expectedChecksum != "" {
return downloadURL, expectedChecksum, nil
}
if checksumURL == "" {
return downloadURL, "", fmt.Errorf("no sha256 digest or checksum asset %q in release %s", checksumAssetName, release.TagName)
}
expectedChecksum, err = s.downloadChecksum(ctx, checksumURL, assetName)
if err != nil {
return downloadURL, "", fmt.Errorf("download checksum: %w", err)
}
return downloadURL, expectedChecksum, nil
}
func normalizeGitHubDigest(digest string) string {
digest = strings.TrimSpace(digest)
if digest == "" {
return ""
}
const prefix = "sha256:"
if strings.HasPrefix(strings.ToLower(digest), prefix) {
digest = digest[len(prefix):]
}
digest = strings.ToLower(digest)
if isSHA256Hex(digest) {
return digest
}
return ""
}
func (s *Service) downloadChecksum(ctx context.Context, url string, assetName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
+96 -4
View File
@@ -77,7 +77,7 @@ func TestGetReleaseByTag(t *testing.T) {
}
}
func TestCheckAndUpdateRequiresChecksumAsset(t *testing.T) {
func TestCheckAndUpdateRequiresChecksumSource(t *testing.T) {
assetName := testService(nil).assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
service := testService(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -98,8 +98,100 @@ func TestCheckAndUpdateRequiresChecksumAsset(t *testing.T) {
})
err := service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
if err == nil || !strings.Contains(err.Error(), "no matching checksum asset") {
t.Fatalf("expected missing checksum asset error, got %v", err)
if err == nil || !strings.Contains(err.Error(), "no sha256 digest or checksum asset") {
t.Fatalf("expected missing checksum source error, got %v", err)
}
}
func TestNormalizeGitHubDigest(t *testing.T) {
checksum := strings.Repeat("a", sha256.Size*2)
testCases := []struct {
name string
input string
want string
}{
{name: "prefixed digest", input: "sha256:" + checksum, want: checksum},
{name: "bare hex", input: checksum, want: checksum},
{name: "empty", input: "", want: ""},
{name: "invalid", input: "sha256:not-a-digest", want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
if got := normalizeGitHubDigest(testCase.input); got != testCase.want {
t.Fatalf("unexpected digest: got %q want %q", got, testCase.want)
}
})
}
}
func TestResolveReleaseAssetPrefersDigest(t *testing.T) {
assetName := testService(nil).assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
checksum := strings.Repeat("b", sha256.Size*2)
service := testService(nil)
downloadURL, expectedChecksum, err := service.resolveReleaseAsset(context.Background(), &githubRelease{
TagName: "v1.0.1",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.test/agent",
Digest: "sha256:" + checksum,
},
{
Name: assetName + ".sha256",
BrowserDownloadURL: "https://example.test/agent.sha256",
},
},
}, assetName)
if err != nil {
t.Fatalf("expected digest resolution to succeed: %v", err)
}
if downloadURL != "https://example.test/agent" {
t.Fatalf("unexpected download url: %s", downloadURL)
}
if expectedChecksum != checksum {
t.Fatalf("unexpected checksum: got %s want %s", expectedChecksum, checksum)
}
}
func TestResolveReleaseAssetFallsBackToChecksumAsset(t *testing.T) {
assetName := testService(nil).assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
checksum := strings.Repeat("c", sha256.Size*2)
service := testService(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://example.test/agent.sha256" {
t.Fatalf("unexpected request url: %s", req.URL.String())
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(checksum + "\n")),
}, nil
}),
})
downloadURL, expectedChecksum, err := service.resolveReleaseAsset(context.Background(), &githubRelease{
TagName: "v1.0.1",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.test/agent",
},
{
Name: assetName + ".sha256",
BrowserDownloadURL: "https://example.test/agent.sha256",
},
},
}, assetName)
if err != nil {
t.Fatalf("expected checksum fallback to succeed: %v", err)
}
if downloadURL != "https://example.test/agent" {
t.Fatalf("unexpected download url: %s", downloadURL)
}
if expectedChecksum != checksum {
t.Fatalf("unexpected checksum: got %s want %s", expectedChecksum, checksum)
}
}
@@ -229,4 +321,4 @@ func TestIsNewerSupportsPrerelease(t *testing.T) {
}
})
}
}
}