fix: 修复应用扫描取消并校验 Sparkle 更新源

This commit is contained in:
ysicing authored and 缘生 committed 2026-09-30 15:20:08 +08:00
1 parent 1c1e94a15f
commit bc2bfe8f5c
10 files changed
+303 -9

No files matched your search

+4 -1
View File
@@ -411,7 +411,10 @@ helper, re-registers the bundled version, and verifies the protocol before privi
`server/api` is one Go program. Fiber exposes `POST /api/v1/update/check`, authenticated
`PUT /api/v1/releases/current`, the fixed signed `GET /api/v1/update/appcast.xml`, and the aggregate
`GET /stats` dashboard. GORM uses
`GET /stats` dashboard. The XML route verifies the original Ed25519 feed signature, RSS structure and
release/archive metadata against the current JSON release before recording a successful check. Invalid
feeds return 502 without changing telemetry; the legacy JSON endpoint remains independent. Both backends
use the client public key and must be updated together if it rotates. Valid XML is returned byte for byte. GORM uses
`github.com/libtnb/sqlite` with WAL and one database connection so concurrent checks cannot compete for
SQLite's single writer. Each installation row stores only the SHA-256 installation ID, current version,
first/last check times and check count; request IPs and monitoring data are not persisted. The release
+1 -1
View File
@@ -172,7 +172,7 @@ git push
```
发布需要本地装有 `mc`(MinIO 客户端,别名 `c-ip` 指向对象存储源站)和 `gh`(GitHub CLI)。
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端原样代理版本文件,合并统计并禁止缓存。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
安装包与已签名的 XML 放在 `https://c.ysicing.net/oss/apps/macOS/XStats/`;上传并回读核验 DMG、ZIP、XML 后才发布原有 JSON API,API 字段和旧客户端协议保持不变。新客户端通过固定 `/api/v1/update/appcast.xml` 获取当前版本的签名 XML,服务端在 256 KiB 上限内核验 Ed25519 签名、RSS 结构和与当前发布一致的版本、构建号、系统要求及安装包元数据,成功后原样代理版本文件,合并统计并禁止缓存。无效内容返回 502,不增加检查统计;旧 JSON 接口独立工作。后端公钥须与 `App/Info.plist` 的 `SUPublicEDKey` 一致,轮换发布密钥时同步更新两套后端。必须先部署双区域 API;发布脚本通过无安装 ID 的只读探测确认入口已上线(400 + no-store),未就绪则阻止发布。GitHub Release 只挂 dmg,
作为应用内「手动下载」和 README 的下载入口。版本徽章需在四个 README 的第 9 行手动更新,
`scripts/sync_changelog.py` 不处理徽章。
@@ -78,19 +78,40 @@ public enum AppUninstaller {
/// /Applications 与 ~/Applications 下的应用(含一层子文件夹),按名称排序
public static func installedApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set<String> = []) -> [InstalledApp] {
(try? installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers, checkCancellation: {})) ?? []
}
/// 可取消的应用列表扫描;遍历中止时抛错,不发布部分列表。
public static func scanInstalledApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set<String> = []) throws -> [InstalledApp] {
try installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers,
checkCancellation: { try Task.checkCancellation() })
}
static func installedApps(in directories: [URL], excluding excludedIdentifiers: Set<String>,
checkCancellation: () throws -> Void) throws -> [InstalledApp] {
try checkCancellation()
var apps: [InstalledApp] = []
let manager = FileManager.default
for directory in applicationDirectories(home: home) {
for directory in directories {
try checkCancellation()
let entries = (try? manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
for entry in entries {
try checkCancellation()
if entry.pathExtension == "app" {
if let app = app(at: entry) { apps.append(app) }
} else if (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
try checkCancellation()
let nested = (try? manager.contentsOfDirectory(at: entry, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
apps += nested.filter { $0.pathExtension == "app" }.compactMap(app(at:))
for candidate in nested {
try checkCancellation()
guard candidate.pathExtension == "app" else { continue }
if let app = app(at: candidate) { apps.append(app) }
}
}
}
}
// 取消不得继续做筛选、排序,也不能把扫描到的一部分列表当成成功结果。
try checkCancellation()
return apps
.filter { !excludedIdentifiers.contains($0.bundleIdentifier) }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
@@ -38,7 +38,7 @@ public final class UninstallerController {
}
init(currentBundleIdentifier: String?,
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { AppUninstaller.installedApps() },
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { try AppUninstaller.scanInstalledApps() },
findLeftovers: @escaping @Sendable (InstalledApp) throws -> [AppLeftover] = { try AppUninstaller.scanLeftovers(for: $0) },
measureSize: @escaping @Sendable (URL) throws -> UInt64 = { try CleanEngine.scanAllocatedSize(of: $0) },
recycleFiles: @escaping ([URL], @escaping @Sendable ([URL: URL], String?) -> Void) -> Void = { urls, completion in
@@ -6,6 +6,45 @@ import Testing
@testable import Cleaner
struct ScanCancellationTests {
@Test func cancellationInterruptsNestedApplicationListingWithoutPartialResults() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let nested = root.appendingPathComponent("Utilities")
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
for index in 0..<20 {
let app = nested.appendingPathComponent("Fixture-\(index).app/Contents")
try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
let info = ["CFBundleIdentifier": "test.list.\(index)", "CFBundleName": "Fixture-\(index)"]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
.write(to: app.appendingPathComponent("Info.plist"))
}
var checks = 0
#expect(throws: CancellationError.self) {
try AppUninstaller.installedApps(in: [root], excluding: []) {
checks += 1
if checks == 7 { throw CancellationError() }
}
}
#expect(checks == 7)
let apps = try AppUninstaller.installedApps(in: [root], excluding: ["test.list.0"], checkCancellation: {})
#expect(apps.count == 19 && !apps.contains { $0.bundleIdentifier == "test.list.0" })
}
@Test func cancelledTaskDoesNotStartApplicationListing() async {
let scan = Task.detached {
withUnsafeCurrentTask { $0?.cancel() }
do {
_ = try AppUninstaller.scanInstalledApps()
return false
} catch is CancellationError {
return true
} catch {
return false
}
}
#expect(await scan.value)
}
@Test func cancellationInterruptsDirectoryTraversalWithoutReturningPartialSize() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+4 -1
View File
@@ -198,7 +198,7 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed
}
return c.JSON(current)
})
// 固定入口选择当前发布对应的不可变 XML;保留签名字节,不解析重写或在服务端持有私钥。
// 固定入口选择当前发布对应的不可变 XML;校验后原样返回,不重写或持有私钥。
// 统计与本次 GET 合并,Sparkle 客户端无需再发一份 JSON POST。
app.Get("/api/v1/update/appcast.xml", checkLimiter, func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "no-store")
@@ -234,6 +234,9 @@ func newApplicationWithFeedClient(databasePath string, releaseToken string, feed
if err != nil || len(data) == 0 || len(data) > maximumFeedBytes {
return c.SendStatus(http.StatusBadGateway)
}
if err := validateSparkleFeed(data, current); err != nil {
return c.SendStatus(http.StatusBadGateway)
}
if err := recordUpdateCheck(database, check); err != nil {
return c.SendStatus(http.StatusInternalServerError)
}
+78 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
@@ -20,6 +21,16 @@ type feedTransport func(*http.Request) (*http.Response, error)
func (f feedTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// 已签名的固定夹具避免在 CI 中读取发布私钥;安装包签名只作结构测试。
func signedSparkleFixture(t *testing.T, version string) string {
t.Helper()
data, err := os.ReadFile("testdata/sparkle-" + version + ".xml")
if err != nil {
t.Fatal(err)
}
return string(data)
}
func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string) {
t.Helper()
body := fmt.Sprintf(`{"version":%q,"build":%q,"date":"2026-09-30","minimumSystem":"14.0","url":"https://example.test/XStats-%s.zip","sha256":"%s","size":1000,"notes":["test"]}`, version, build, version, strings.Repeat("a", 64))
@@ -37,7 +48,7 @@ func publishSparkleFixture(t *testing.T, app *fiber.App, version, build string)
}
func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
bytes := "<?xml version=\"1.0\"?>\n<rss/>\n<!-- sparkle:edSignature test -->\n"
bytes := signedSparkleFixture(t, "0.14.1")
var urls []string
client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) {
urls = append(urls, r.URL.String())
@@ -54,6 +65,7 @@ func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
endpoint := "/api/v1/update/appcast.xml?current_version=0.14.0&installation_id=" + strings.Repeat("b", 64)
for index, version := range []string{"0.14.1", "0.14.2"} {
publishSparkleFixture(t, app, version, fmt.Sprint(126+index))
bytes = signedSparkleFixture(t, version)
res, err := app.Test(httptest.NewRequest(http.MethodGet, endpoint, nil))
if err != nil {
t.Fatal(err)
@@ -106,13 +118,14 @@ func TestSparkleStableFeedPreservesBytesAndTracksOncePerGet(t *testing.T) {
}
func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T) {
for _, body := range []string{"", strings.Repeat("x", 256*1024+1)} {
for _, body := range []string{"", strings.Repeat("x", 256*1024+1), "<html>CDN error</html>", "<rss>", "<rss/>", strings.Replace(signedSparkleFixture(t, "0.14.1"), "XStats updates", "tampered", 1), signedSparkleFixture(t, "0.14.2")} {
calls := 0
client := &http.Client{Transport: feedTransport(func(r *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: r}, nil
})}
app, err := newApplicationWithFeedClient(t.TempDir()+"/xstats.sqlite", "release-secret", client)
databasePath := t.TempDir() + "/xstats.sqlite"
app, err := newApplicationWithFeedClient(databasePath, "release-secret", client)
if err != nil {
t.Fatal(err)
}
@@ -138,5 +151,67 @@ func TestSparkleFeedRejectsInvalidInputsAndBoundedUpstreamFailures(t *testing.T)
if res.StatusCode != 502 {
t.Fatal(res.StatusCode)
}
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
connection, err := db.DB()
if err != nil {
t.Fatal(err)
}
var count int64
err = db.Model(&installation{}).Count(&count).Error
connection.Close()
if err != nil || count != 0 {
t.Fatalf("invalid feed tracked %d: %v", count, err)
}
legacy := httptest.NewRequest(http.MethodPost, "/api/v1/update/check", strings.NewReader(`{"current_version":"0.12.1","installation_id":"`+strings.Repeat("a", 64)+`"}`))
legacy.Header.Set("Content-Type", "application/json")
res, err = app.Test(legacy)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 200 {
t.Fatalf("legacy update: %d", res.StatusCode)
}
}
}
// 保持同一份有效签名,改变发布指针的各项元数据必须被拒绝。
func TestSparkleFeedValidatesPublishedMetadataAndSignature(t *testing.T) {
feed := signedSparkleFixture(t, "0.14.1")
published := release{Version: "0.14.1", Build: "126", MinimumSystem: "14.0", URL: "https://example.test/XStats-0.14.1.zip", Size: 1000, SHA256: strings.Repeat("a", 64)}
if err := validateSparkleFeed([]byte(feed), published); err != nil {
t.Fatal(err)
}
for _, field := range []string{"version", "build", "minimumSystem", "url", "size", "sha256"} {
t.Run(field, func(t *testing.T) {
altered := published
switch field {
case "version":
altered.Version = "0.14.2"
case "build":
altered.Build = "127"
case "minimumSystem":
altered.MinimumSystem = "15.0"
case "url":
altered.URL = "https://example.test/other.zip"
case "size":
altered.Size++
case "sha256":
altered.SHA256 = strings.Repeat("b", 64)
}
if validateSparkleFeed([]byte(feed), altered) == nil {
t.Fatal("mismatch accepted")
}
})
}
signatureStart := strings.LastIndex(feed, "<!-- sparkle-signatures:")
unsigned := feed[:signatureStart]
for _, broken := range []string{unsigned, feed + "<rss/>", strings.Replace(feed, "length:", "invalid:", 1), strings.Replace(feed, "edSignature:", "edSignature:broken", 1), strings.Replace(feed, "XStats updates", "tampered", 1)} {
if validateSparkleFeed([]byte(broken), published) == nil {
t.Fatal("invalid signature accepted")
}
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/xml"
"errors"
"io"
"strconv"
"strings"
)
// 与客户端 App/Info.plist 的 SUPublicEDKey 一致;更换发布密钥时同步更新。
const sparklePublicKey = "j/I8zd8BXz4oPldnhocEySXTMmXPxKMyBRwoA4ewLIk="
type sparkleRSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channels []struct {
Items []struct {
Build string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle version"`
Version string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle shortVersionString"`
MinimumSystem string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle minimumSystemVersion"`
Hardware string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle hardwareRequirements"`
SHA256 string `xml:"https://github.com/ysicing/xstats/appcast sha256"`
Enclosures []struct {
URL string `xml:"url,attr"`
Size int64 `xml:"length,attr"`
Signature string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle edSignature,attr"`
InstallationType string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle installationType,attr"`
} `xml:"enclosure"`
} `xml:"item"`
} `xml:"channel"`
}
func validateSparkleFeed(data []byte, current release) error {
// Sparkle 2.10 将整个 XML 正文签名后附加此注释块;必须对原始字节验签。
prefix := []byte("<!-- sparkle-signatures:\n")
start := bytes.LastIndex(data, prefix)
if start < 0 {
return errors.New("missing feed signature")
}
block := data[start+len(prefix):]
end := bytes.Index(block, []byte("-->"))
if end < 0 || len(bytes.TrimSpace(block[end+3:])) != 0 {
return errors.New("invalid signature block")
}
lines := strings.Split(strings.TrimSpace(string(block[:end])), "\n")
if len(lines) != 2 || !strings.HasPrefix(lines[0], "edSignature:") || !strings.HasPrefix(lines[1], "length:") {
return errors.New("invalid signature fields")
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(lines[0], "edSignature:")))
if err != nil || len(signature) != ed25519.SignatureSize {
return errors.New("invalid feed signature")
}
length, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(lines[1], "length:")))
if err != nil || length != start {
return errors.New("invalid signed length")
}
key, err := base64.StdEncoding.DecodeString(sparklePublicKey)
if err != nil || len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, data[:start], signature) {
return errors.New("feed signature verification failed")
}
var rss sparkleRSS
decoder := xml.NewDecoder(bytes.NewReader(data[:start]))
if err := decoder.Decode(&rss); err != nil {
return err
}
// Decode 只读第一个元素,拒绝正文末尾的第二个根节点或无效 XML。
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return err
}
switch token := token.(type) {
case xml.Comment:
case xml.CharData:
if len(bytes.TrimSpace(token)) != 0 {
return errors.New("unexpected content after feed")
}
default:
return errors.New("unexpected content after feed")
}
}
if rss.XMLName.Space != "" || rss.Version != "2.0" || len(rss.Channels) != 1 || len(rss.Channels[0].Items) != 1 {
return errors.New("invalid RSS channel or items")
}
item := rss.Channels[0].Items[0]
if item.Version != current.Version || item.Build != current.Build || item.MinimumSystem != current.MinimumSystem || item.Hardware != "arm64" || item.SHA256 != current.SHA256 || len(item.Enclosures) != 1 {
return errors.New("feed does not match published release")
}
enclosure := item.Enclosures[0]
archiveSignature, err := base64.StdEncoding.DecodeString(enclosure.Signature)
if err != nil || len(archiveSignature) != ed25519.SignatureSize || enclosure.InstallationType != "application" || enclosure.URL != current.URL || enclosure.Size != current.Size {
return errors.New("invalid update enclosure")
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- sparkle-sign-warning:
IMPORTANT: This file was signed by Sparkle. Any modifications to this file requires re-signing this file with generate_appcast or sign_update! The signed signature will be embedded at the end of this file.
--><!-- SPDX-License-Identifier: AGPL-3.0-or-later; test metadata only, not an installable update. --><rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:xstats="https://github.com/ysicing/xstats/appcast" version="2.0">
<channel>
<title>XStats</title>
<link>https://github.com/ysicing/xstats</link>
<description>XStats updates</description>
<item>
<title>XStats 0.14.1</title>
<sparkle:version>126</sparkle:version>
<sparkle:shortVersionString>0.14.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<description xml:lang="zh-Hans" sparkle:format="plain-text">test</description>
<xstats:notes-zh-Hans>test</xstats:notes-zh-Hans>
<xstats:date>2026-09-30</xstats:date>
<xstats:sha256>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</xstats:sha256>
<enclosure url="https://example.test/XStats-0.14.1.zip" length="1000" type="application/octet-stream" sparkle:edSignature="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" sparkle:installationType="application"></enclosure>
</item>
</channel>
</rss><!-- sparkle-signatures:
edSignature: SgYaNl8guFoJ1FjdrrXpe0Jzj8CbeBCkAQLFdGtZ1Xuczh3YEBiGWI31wQUlF8NyrUsxyh+8jFv5oRJjuiYIBQ==
length: 1565
-->
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?><!-- sparkle-sign-warning:
IMPORTANT: This file was signed by Sparkle. Any modifications to this file requires re-signing this file with generate_appcast or sign_update! The signed signature will be embedded at the end of this file.
--><!-- SPDX-License-Identifier: AGPL-3.0-or-later; test metadata only, not an installable update. --><rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:xstats="https://github.com/ysicing/xstats/appcast" version="2.0">
<channel>
<title>XStats</title>
<link>https://github.com/ysicing/xstats</link>
<description>XStats updates</description>
<item>
<title>XStats 0.14.2</title>
<sparkle:version>127</sparkle:version>
<sparkle:shortVersionString>0.14.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<description xml:lang="zh-Hans" sparkle:format="plain-text">test</description>
<xstats:notes-zh-Hans>test</xstats:notes-zh-Hans>
<xstats:date>2026-09-30</xstats:date>
<xstats:sha256>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</xstats:sha256>
<enclosure url="https://example.test/XStats-0.14.2.zip" length="1000" type="application/octet-stream" sparkle:edSignature="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" sparkle:installationType="application"></enclosure>
</item>
</channel>
</rss><!-- sparkle-signatures:
edSignature: 9yHU+UlcaIppXEsdJkvWywed+IZUDhZiXCugaaBB0tGCIOU+Z5IKv43fYCv928oszMuNYGEao65RvojO8SO8Cw==
length: 1565
-->