mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
Verifies downloaded app’s permissions match source
Renames source JSON permissions key to “appPermissions” in order to preserve backwards compatibility, since we’ve changed the schema for permissions.
This commit is contained in:
@@ -23,6 +23,8 @@ extension VerificationError
|
||||
|
||||
case mismatchedHash = 3
|
||||
case mismatchedVersion = 4
|
||||
|
||||
case undeclaredPermissions = 6
|
||||
}
|
||||
|
||||
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
|
||||
@@ -40,6 +42,10 @@ extension VerificationError
|
||||
static func mismatchedVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError {
|
||||
VerificationError(code: .mismatchedVersion, app: app, version: version, expectedVersion: expectedVersion)
|
||||
}
|
||||
|
||||
static func undeclaredPermissions(_ permissions: [any ALTAppPermission], app: AppProtocol) -> VerificationError {
|
||||
VerificationError(code: .undeclaredPermissions, app: app, permissions: permissions)
|
||||
}
|
||||
}
|
||||
|
||||
struct VerificationError: ALTLocalizedError
|
||||
@@ -60,6 +66,9 @@ struct VerificationError: ALTLocalizedError
|
||||
@UserInfoValue var version: String?
|
||||
@UserInfoValue var expectedVersion: String?
|
||||
|
||||
@UserInfoValue
|
||||
var permissions: [any ALTAppPermission]?
|
||||
|
||||
var errorDescription: String? {
|
||||
//TODO: Make this automatic somehow with ALTLocalizedError
|
||||
guard self.errorFailure == nil else { return nil }
|
||||
@@ -129,6 +138,52 @@ struct VerificationError: ALTLocalizedError
|
||||
case .mismatchedVersion:
|
||||
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
|
||||
return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.", comment: ""), appName)
|
||||
|
||||
case .undeclaredPermissions:
|
||||
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
|
||||
return String(format: NSLocalizedString("%@ requires additional permissions not specified by the source.", comment: ""), appName)
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self.code
|
||||
{
|
||||
case .undeclaredPermissions:
|
||||
guard let permissions, !permissions.isEmpty else { return nil }
|
||||
|
||||
let baseMessage = NSLocalizedString("These permissions must be declared by the source in order for AltStore to install this app:", comment: "")
|
||||
|
||||
let permissionsByType = Dictionary(grouping: permissions) { $0.type }
|
||||
let permissionSections = [ALTAppPermissionType.entitlement, .privacy, .backgroundMode].compactMap { (type) -> String? in
|
||||
guard let permissions = permissionsByType[type] else { return nil }
|
||||
|
||||
// "Privacy:"
|
||||
var sectionText = "\(type.localizedName ?? type.rawValue):\n"
|
||||
|
||||
// Sort permissions + join into single string.
|
||||
let sortedList = permissions.map { permission -> String in
|
||||
if let localizedName = permission.localizedName
|
||||
{
|
||||
// "Entitlement Name (com.apple.entitlement.name)"
|
||||
return "\(localizedName) (\(permission.rawValue))"
|
||||
}
|
||||
else
|
||||
{
|
||||
// "com.apple.entitlement.name"
|
||||
return permission.rawValue
|
||||
}
|
||||
}
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending } // Case-insensitive sorting
|
||||
.joined(separator: "\n")
|
||||
|
||||
sectionText += sortedList
|
||||
return sectionText
|
||||
}
|
||||
|
||||
let recoverySuggestion = ([baseMessage] + permissionSections).joined(separator: "\n\n")
|
||||
return recoverySuggestion
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,16 @@ struct VerificationError: ALTLocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
import RegexBuilder
|
||||
|
||||
private extension ALTEntitlement
|
||||
{
|
||||
static var ignoredEntitlements: Set<ALTEntitlement> = [
|
||||
.applicationIdentifier,
|
||||
.teamIdentifier
|
||||
]
|
||||
}
|
||||
|
||||
@objc(VerifyAppOperation)
|
||||
final class VerifyAppOperation: ResultOperation<Void>
|
||||
{
|
||||
@@ -146,6 +156,11 @@ final class VerifyAppOperation: ResultOperation<Void>
|
||||
try await self.verifyHash(of: app, at: ipaURL, matches: appVersion)
|
||||
try await self.verifyDownloadedVersion(of: app, matches: appVersion)
|
||||
|
||||
if let storeApp = await self.context.$appVersion.app
|
||||
{
|
||||
try await self.verifyPermissions(of: app, match: storeApp)
|
||||
}
|
||||
|
||||
self.finish(.success(()))
|
||||
}
|
||||
catch
|
||||
@@ -183,4 +198,81 @@ private extension VerifyAppOperation
|
||||
|
||||
guard version == app.version else { throw VerificationError.mismatchedVersion(app.version, expectedVersion: version, app: app) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func verifyPermissions(of app: ALTApplication, @AsyncManaged match storeApp: StoreApp) async throws -> [any ALTAppPermission]
|
||||
{
|
||||
// Entitlements
|
||||
var allEntitlements = Set(app.entitlements.keys)
|
||||
for appExtension in app.appExtensions
|
||||
{
|
||||
allEntitlements.formUnion(appExtension.entitlements.keys)
|
||||
}
|
||||
|
||||
// Filter out ignored entitlements.
|
||||
allEntitlements = allEntitlements.filter { !ALTEntitlement.ignoredEntitlements.contains($0) }
|
||||
|
||||
|
||||
// Background Modes
|
||||
// App extensions can't have background modes, so don't need to worry about them.
|
||||
let allBackgroundModes: Set<ALTAppBackgroundMode>
|
||||
if let backgroundModes = app.bundle.infoDictionary?[Bundle.Info.backgroundModes] as? [String]
|
||||
{
|
||||
let backgroundModes = backgroundModes.lazy.map { ALTAppBackgroundMode($0) }
|
||||
allBackgroundModes = Set(backgroundModes)
|
||||
}
|
||||
else
|
||||
{
|
||||
allBackgroundModes = []
|
||||
}
|
||||
|
||||
|
||||
// Privacy
|
||||
let allPrivacyPermissions: Set<ALTAppPrivacyPermission>
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
let regex = Regex {
|
||||
"NS"
|
||||
|
||||
// Capture permission "name"
|
||||
Capture {
|
||||
OneOrMore(.anyGraphemeCluster)
|
||||
}
|
||||
|
||||
"UsageDescription"
|
||||
|
||||
// Optional suffix
|
||||
Optionally(OneOrMore(.anyGraphemeCluster))
|
||||
}
|
||||
|
||||
let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
|
||||
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
|
||||
guard let match = key.wholeMatch(of: regex) else { return nil }
|
||||
|
||||
let permission = ALTAppPrivacyPermission(rawValue: String(match.1))
|
||||
return permission
|
||||
} ?? []
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
allPrivacyPermissions = Set(privacyPermissions)
|
||||
}
|
||||
else
|
||||
{
|
||||
allPrivacyPermissions = []
|
||||
}
|
||||
|
||||
|
||||
// Verify permissions.
|
||||
let sourcePermissions: Set<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } })
|
||||
let localPermissions: [any ALTAppPermission] = Array(allEntitlements) + Array(allBackgroundModes) + Array(allPrivacyPermissions)
|
||||
|
||||
// To pass: EVERY permission in localPermissions must also appear in sourcePermissions.
|
||||
// If there is a single missing permission, throw error.
|
||||
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) }
|
||||
guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) }
|
||||
|
||||
return localPermissions
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user