Skip to content

Commit f4d1db6

Browse files
leogdionclaude
andcommitted
feat: add VirtualBuddy TSS signing status verification (#1)
Integrates tss.virtualbuddy.app to verify real-time signing status of macOS restore images. VirtualBuddy signing status is treated as authoritative (same priority as MESU) and enriches existing images with TSS verification results. - Add BushelVirtualBuddy dependency from BushelKit - Implement VirtualBuddyFetcher with enrichment pattern - Update merge logic to treat VirtualBuddy as authoritative source - Add VIRTUALBUDDY_API_KEY environment variable support - Graceful degradation when API key unavailable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7364c82 commit f4d1db6

File tree

3 files changed

+191
-6
lines changed

3 files changed

+191
-6
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ let package = Package(
105105
.product(name: "BushelLogging", package: "BushelKit"),
106106
.product(name: "BushelFoundation", package: "BushelKit"),
107107
.product(name: "BushelUtilities", package: "BushelKit"),
108+
.product(name: "BushelVirtualBuddy", package: "BushelKit"),
108109
.product(name: "IPSWDownloads", package: "IPSWDownloads"),
109110
.product(name: "SwiftSoup", package: "SwiftSoup")
110111
],

Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public struct DataSourcePipeline: Sendable {
4242
public var includeBetaReleases: Bool = true
4343
public var includeAppleDB: Bool = true
4444
public var includeTheAppleWiki: Bool = true
45+
public var includeVirtualBuddy: Bool = true
4546
public var force: Bool = false
4647
public var specificSource: String?
4748

@@ -52,6 +53,7 @@ public struct DataSourcePipeline: Sendable {
5253
includeBetaReleases: Bool = true,
5354
includeAppleDB: Bool = true,
5455
includeTheAppleWiki: Bool = true,
56+
includeVirtualBuddy: Bool = true,
5557
force: Bool = false,
5658
specificSource: String? = nil
5759
) {
@@ -61,6 +63,7 @@ public struct DataSourcePipeline: Sendable {
6163
self.includeBetaReleases = includeBetaReleases
6264
self.includeAppleDB = includeAppleDB
6365
self.includeTheAppleWiki = includeTheAppleWiki
66+
self.includeVirtualBuddy = includeVirtualBuddy
6467
self.force = force
6568
self.specificSource = specificSource
6669
}
@@ -306,6 +309,20 @@ public struct DataSourcePipeline: Sendable {
306309
}
307310
}
308311

312+
// Enrich with VirtualBuddy TSS signing status
313+
if options.includeVirtualBuddy, let fetcher = VirtualBuddyFetcher() {
314+
do {
315+
let enrichedImages = try await fetcher.fetch(existingImages: allImages)
316+
allImages = enrichedImages
317+
print(" ✓ VirtualBuddy: Enriched \(allImages.count) images with signing status")
318+
} catch {
319+
print(" ⚠️ VirtualBuddy failed: \(error)")
320+
// Don't throw - continue with original data
321+
}
322+
} else if options.includeVirtualBuddy {
323+
print(" ⚠️ VirtualBuddy: No API key found (set VIRTUALBUDDY_API_KEY)")
324+
}
325+
309326
// Deduplicate by build number (keep first occurrence)
310327
let preDedupeCount = allImages.count
311328
let deduped = deduplicateRestoreImages(allImages)
@@ -397,14 +414,17 @@ public struct DataSourcePipeline: Sendable {
397414
}
398415

399416
// Merge isSigned with priority rules:
400-
// 1. MESU is always authoritative (Apple's real-time signing status)
401-
// 2. For non-MESU sources, prefer the most recently updated
417+
// 1. MESU or VirtualBuddy are always authoritative (Apple's real-time signing status)
418+
// 2. For other sources, prefer the most recently updated
402419
// 3. If both have same update time (or both nil) and disagree, prefer false
403420

404-
if first.source == "mesu.apple.com" && first.isSigned != nil {
405-
merged.isSigned = first.isSigned // MESU first is authoritative
406-
} else if second.source == "mesu.apple.com" && second.isSigned != nil {
407-
merged.isSigned = second.isSigned // MESU second is authoritative
421+
// Define authoritative sources for signing status
422+
let authoritativeSources: Set<String> = ["mesu.apple.com", "tss.virtualbuddy.app"]
423+
424+
if authoritativeSources.contains(first.source) && first.isSigned != nil {
425+
merged.isSigned = first.isSigned // First is authoritative (MESU or VirtualBuddy)
426+
} else if authoritativeSources.contains(second.source) && second.isSigned != nil {
427+
merged.isSigned = second.isSigned // Second is authoritative (MESU or VirtualBuddy)
408428
} else {
409429
// Neither is MESU, compare update timestamps
410430
let firstUpdated = first.sourceUpdatedAt
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//
2+
// VirtualBuddyFetcher.swift
3+
// BushelCloud
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the "Software"), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
import BushelFoundation
31+
import BushelUtilities
32+
import BushelVirtualBuddy
33+
import Foundation
34+
import OSVer
35+
36+
#if canImport(FoundationNetworking)
37+
import FoundationNetworking
38+
#endif
39+
40+
/// Fetcher for enriching restore images with VirtualBuddy TSS signing status
41+
struct VirtualBuddyFetcher: DataSourceFetcher, Sendable {
42+
typealias Record = [RestoreImageRecord]
43+
44+
private let apiKey: String
45+
private let decoder: JSONDecoder
46+
private let urlSession: URLSession
47+
48+
/// Failable initializer that reads API key from environment variable
49+
init?() {
50+
guard let key = ProcessInfo.processInfo.environment["VIRTUALBUDDY_API_KEY"], !key.isEmpty else {
51+
return nil
52+
}
53+
self.apiKey = key
54+
self.decoder = JSONDecoder()
55+
#if canImport(FoundationNetworking)
56+
self.urlSession = FoundationNetworking.URLSession.shared
57+
#else
58+
self.urlSession = URLSession.shared
59+
#endif
60+
}
61+
62+
/// Explicit initializer with API key
63+
init(apiKey: String, decoder: JSONDecoder = JSONDecoder(), urlSession: URLSession = .shared) {
64+
self.apiKey = apiKey
65+
self.decoder = decoder
66+
#if canImport(FoundationNetworking)
67+
self.urlSession = FoundationNetworking.URLSession.shared
68+
#else
69+
self.urlSession = urlSession
70+
#endif
71+
}
72+
73+
/// DataSourceFetcher protocol requirement - returns empty for enrichment fetchers
74+
func fetch() async throws -> [RestoreImageRecord] {
75+
return []
76+
}
77+
78+
/// Enrich existing restore images with VirtualBuddy TSS signing status
79+
func fetch(existingImages: [RestoreImageRecord]) async throws -> [RestoreImageRecord] {
80+
var enrichedImages: [RestoreImageRecord] = []
81+
82+
for image in existingImages {
83+
// Skip file URLs (VirtualBuddy API doesn't support them)
84+
guard image.downloadURL.scheme != "file" else {
85+
enrichedImages.append(image)
86+
continue
87+
}
88+
89+
do {
90+
let response = try await checkSigningStatus(for: image.downloadURL)
91+
92+
// Validate build number matches
93+
guard response.build == image.buildNumber else {
94+
print(
95+
" ⚠️ VirtualBuddy build mismatch: expected \(image.buildNumber), got \(response.build)"
96+
)
97+
enrichedImages.append(image)
98+
continue
99+
}
100+
101+
// Create enriched record with VirtualBuddy data
102+
var enriched = image
103+
enriched.isSigned = response.isSigned
104+
enriched.source = "tss.virtualbuddy.app"
105+
enriched.sourceUpdatedAt = Date() // Real-time TSS check
106+
enriched.notes = response.message // TSS status message
107+
108+
enrichedImages.append(enriched)
109+
} catch {
110+
print(" ⚠️ VirtualBuddy error for \(image.buildNumber): \(error)")
111+
enrichedImages.append(image) // Keep original on error
112+
}
113+
}
114+
115+
return enrichedImages
116+
}
117+
118+
/// Check signing status for an IPSW URL using VirtualBuddy TSS API
119+
private func checkSigningStatus(for ipswURL: URL) async throws -> VirtualBuddySig {
120+
// Build endpoint URL with API key and IPSW URL
121+
var components = URLComponents(string: "https://tss.virtualbuddy.app/v1/status")!
122+
components.queryItems = [
123+
URLQueryItem(name: "apiKey", value: apiKey),
124+
URLQueryItem(name: "ipsw", value: ipswURL.absoluteString)
125+
]
126+
127+
guard let endpointURL = components.url else {
128+
throw VirtualBuddyFetcherError.invalidURL
129+
}
130+
131+
// Fetch data from API
132+
let data: Data
133+
let response: URLResponse
134+
do {
135+
#if canImport(FoundationNetworking)
136+
(data, response) = try await urlSession.data(from: endpointURL)
137+
#else
138+
(data, response) = try await urlSession.data(from: endpointURL)
139+
#endif
140+
} catch {
141+
throw VirtualBuddyFetcherError.networkError(error)
142+
}
143+
144+
// Check HTTP status
145+
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
146+
throw VirtualBuddyFetcherError.httpError(httpResponse.statusCode)
147+
}
148+
149+
// Decode response
150+
do {
151+
return try decoder.decode(VirtualBuddySig.self, from: data)
152+
} catch {
153+
throw VirtualBuddyFetcherError.decodingError(error)
154+
}
155+
}
156+
}
157+
158+
/// Errors that can occur during VirtualBuddy fetching
159+
enum VirtualBuddyFetcherError: Error {
160+
case invalidURL
161+
case networkError(Error)
162+
case httpError(Int)
163+
case decodingError(Error)
164+
}

0 commit comments

Comments
 (0)