diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2090bfa..4afdc0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dac89d7..cc56b1b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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` 不处理徽章。 diff --git a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift index 0530cfc..f47ab5a 100644 --- a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift +++ b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -78,19 +78,40 @@ public enum AppUninstaller { /// /Applications 与 ~/Applications 下的应用(含一层子文件夹),按名称排序 public static func installedApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set = []) -> [InstalledApp] { + (try? installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers, checkCancellation: {})) ?? [] + } + + /// 可取消的应用列表扫描;遍历中止时抛错,不发布部分列表。 + public static func scanInstalledApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set = []) throws -> [InstalledApp] { + try installedApps(in: applicationDirectories(home: home), excluding: excludedIdentifiers, + checkCancellation: { try Task.checkCancellation() }) + } + + static func installedApps(in directories: [URL], excluding excludedIdentifiers: Set, + 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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift index 1ca828e..f9a9fea 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift @@ -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 diff --git a/Packages/XStatsKit/Tests/CleanerTests/ScanCancellationTests.swift b/Packages/XStatsKit/Tests/CleanerTests/ScanCancellationTests.swift index dcab4b3..0e5f41c 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/ScanCancellationTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/ScanCancellationTests.swift @@ -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) diff --git a/server/api/main.go b/server/api/main.go index ad1a7cd..11ffe55 100644 --- a/server/api/main.go +++ b/server/api/main.go @@ -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) } diff --git a/server/api/sparkle_test.go b/server/api/sparkle_test.go index a37738e..adc5e9c 100644 --- a/server/api/sparkle_test.go +++ b/server/api/sparkle_test.go @@ -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 := "\n\n\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), "CDN error", "", "", 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, "")) + 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 +} diff --git a/server/api/testdata/sparkle-0.14.1.xml b/server/api/testdata/sparkle-0.14.1.xml new file mode 100644 index 0000000..71d5974 --- /dev/null +++ b/server/api/testdata/sparkle-0.14.1.xml @@ -0,0 +1,24 @@ + + + XStats + https://github.com/ysicing/xstats + XStats updates + + XStats 0.14.1 + 126 + 0.14.1 + 14.0 + arm64 + test + test + 2026-09-30 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + + + diff --git a/server/api/testdata/sparkle-0.14.2.xml b/server/api/testdata/sparkle-0.14.2.xml new file mode 100644 index 0000000..368dcfb --- /dev/null +++ b/server/api/testdata/sparkle-0.14.2.xml @@ -0,0 +1,24 @@ + + + XStats + https://github.com/ysicing/xstats + XStats updates + + XStats 0.14.2 + 127 + 0.14.2 + 14.0 + arm64 + test + test + 2026-09-30 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + + +