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:
Riley Testut
2023-05-12 18:26:24 -05:00
committed by Magesh K
parent f884d72a8b
commit ee410605e8
16 changed files with 452 additions and 127 deletions

View File

@@ -27,9 +27,18 @@
</uniquenessConstraints>
</entity>
<entity name="AppPermission" representedClassName="AppPermission" syncable="YES">
<attribute name="appBundleID" optional="YES" attributeType="String"/>
<attribute name="permission" attributeType="String"/>
<attribute name="type" attributeType="String"/>
<attribute name="usageDescription" attributeType="String"/>
<attribute name="usageDescription" optional="YES" attributeType="String"/>
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="permissions" inverseEntity="StoreApp"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="appBundleID"/>
<constraint value="permission"/>
<constraint value="type"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
@@ -191,7 +200,7 @@
<relationship name="latestVersion" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="AppVersion" inverseName="latestVersionApp" inverseEntity="AppVersion"/>
<relationship name="loggedErrors" toMany="YES" deletionRule="Nullify" destinationEntity="LoggedError" inverseName="storeApp" inverseEntity="LoggedError"/>
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source"/>
<relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
<uniquenessConstraints>

View File

@@ -9,6 +9,7 @@
import CoreData
import UIKit
import AltSign
public extension ALTAppPermissionType
{
var localizedShortName: String? {
@@ -71,15 +72,29 @@ public extension ALTAppPermissionType
}
}
@objc(AppPermission)
@objc(AppPermission) @dynamicMemberLookup
public class AppPermission: NSManagedObject, Decodable, Fetchable
{
/* Properties */
@NSManaged public var type: ALTAppPermissionType
@NSManaged public var usageDescription: String
@NSManaged public var usageDescription: String?
@nonobjc public var permission: any ALTAppPermission {
switch self.type
{
case .entitlement: return ALTEntitlement(rawValue: self._permission)
case .privacy: return ALTAppPrivacyPermission(rawValue: self._permission)
case .backgroundMode: return ALTAppBackgroundMode(rawValue: self._permission)
default: return UnknownAppPermission(rawValue: self._permission)
}
}
@NSManaged @objc(permission) private var _permission: String
// Set by StoreApp.
@NSManaged public var appBundleID: String?
/* Relationships */
@NSManaged public private(set) var app: StoreApp!
@NSManaged public internal(set) var app: StoreApp?
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
@@ -88,7 +103,10 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
private enum CodingKeys: String, CodingKey
{
case type
case entitlement
case privacyType = "privacy"
case backgroundMode = "background"
case usageDescription
}
@@ -101,10 +119,33 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
do
{
let container = try decoder.container(keyedBy: CodingKeys.self)
self.usageDescription = try container.decode(String.self, forKey: .usageDescription)
let rawType = try container.decode(String.self, forKey: .type)
self.type = ALTAppPermissionType(rawValue: rawType)
self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription)
if let entitlement = try container.decodeIfPresent(String.self, forKey: .entitlement)
{
self._permission = entitlement
self.type = .entitlement
}
else if let privacyType = try container.decodeIfPresent(String.self, forKey: .privacyType)
{
self._permission = privacyType
self.type = .privacy
}
else if let backgroundMode = try container.decodeIfPresent(String.self, forKey: .backgroundMode)
{
self._permission = backgroundMode
self.type = .backgroundMode
}
else
{
self._permission = ""
self.type = .unknown
// We don't want to save any unknown permissions, but can't throw error
// without making the entire decoding fail, so just delete self instead.
context.delete(self)
}
}
catch
{
@@ -125,3 +166,14 @@ public extension AppPermission
return NSFetchRequest<AppPermission>(entityName: "AppPermission")
}
}
// @dynamicMemberLookup
public extension AppPermission
{
// Convenience for accessing .permission properties.
subscript<T>(dynamicMember keyPath: KeyPath<any ALTAppPermission, T>) -> T {
get {
return self.permission[keyPath: keyPath]
}
}
}

View File

@@ -19,10 +19,12 @@ extension MergeError
case noVersions
case incorrectVersionOrder
case incorrectPermissions
}
static func noVersions(for app: StoreApp) -> MergeError { .init(code: .noVersions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) }
static func incorrectVersionOrder(for app: StoreApp) -> MergeError { .init(code: .incorrectVersionOrder, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) }
static func incorrectPermissions(for app: StoreApp) -> MergeError { .init(code: .incorrectPermissions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) }
}
public struct MergeError: ALTLocalizedError
@@ -57,6 +59,15 @@ public struct MergeError: ALTLocalizedError
}
return String(format: NSLocalizedString("The cached versions for %@ do not match the source.", comment: ""), appName)
case .incorrectPermissions:
var appName = NSLocalizedString("one or more apps", comment: "")
if let name = self.appName, let bundleID = self.appBundleID
{
appName = name + " (\(bundleID))"
}
return String(format: NSLocalizedString("The cached permissions for %@ do not match the source.", comment: ""), appName)
}
}
@@ -134,8 +145,8 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
if let previousApp = conflict.conflictingObjects.first(where: { !$0.isInserted }) as? StoreApp
{
// Delete previous permissions (same as below).
for permission in previousApp.permissions
// Delete previous permissions (different than below).
for case let permission as AppPermission in previousApp._permissions where permission.app == nil
{
permission.managedObjectContext?.delete(permission)
}
@@ -171,6 +182,8 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
}
var sortedVersionsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
var featuredAppIDsBySourceID = [String: [String]]()
for conflict in conflicts
@@ -178,28 +191,28 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
switch conflict.databaseObject
{
case let databaseObject as StoreApp:
// Delete previous permissions
for permission in databaseObject.permissions
guard let contextApp = conflict.conflictingObjects.first as? StoreApp else { break }
// Permissions
let contextPermissions = Set(contextApp._permissions.lazy.compactMap { $0 as? AppPermission }.map { AnyHashable($0.permission) })
for case let databasePermission as AppPermission in databaseObject._permissions where !contextPermissions.contains(AnyHashable(databasePermission.permission))
{
permission.managedObjectContext?.delete(permission)
// Permission does NOT exist in context, so delete existing databasePermission.
databasePermission.managedObjectContext?.delete(databasePermission)
}
if let contextApp = conflict.conflictingObjects.first as? StoreApp
// Versions
let contextVersions = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version })
for case let databaseVersion as AppVersion in databaseObject._versions where !contextVersions.contains(databaseVersion.version)
{
let contextVersions = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version })
for case let appVersion as AppVersion in databaseObject._versions where !contextVersions.contains(appVersion.version)
{
// Version # does NOT exist in context, so delete existing appVersion.
appVersion.managedObjectContext?.delete(appVersion)
}
if let globallyUniqueID = contextApp.globallyUniqueID
{
// Core Data _normally_ preserves the correct ordering of versions when merging,
// but just in case we cache the order and reorder the versions post-merge if needed.
sortedVersionsByGlobalAppID[globallyUniqueID] = contextVersions
}
// Version # does NOT exist in context, so delete existing databaseVersion.
databaseVersion.managedObjectContext?.delete(databaseVersion)
}
if let globallyUniqueID = contextApp.globallyUniqueID
{
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
sortedVersionsByGlobalAppID[globallyUniqueID] = contextVersions
}
case let databaseObject as Source:
@@ -244,36 +257,44 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
case let databaseObject as StoreApp:
do
{
let appVersions: [AppVersion]
var appVersions = databaseObject.versions
if let globallyUniqueID = databaseObject.globallyUniqueID,
let sortedAppVersions = sortedVersionsByGlobalAppID[globallyUniqueID],
let sortedAppVersionsArray = sortedAppVersions.array as? [String],
case let databaseVersions = databaseObject.versions.map({ $0.version }),
databaseVersions != sortedAppVersionsArray
if let globallyUniqueID = databaseObject.globallyUniqueID
{
// databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting.
let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in
let indexA = sortedAppVersions.index(of: versionA.version)
let indexB = sortedAppVersions.index(of: versionB.version)
return indexA < indexB
// Permissions
if let appPermissions = permissionsByGlobalAppID[globallyUniqueID],
case let databasePermissions = Set(databaseObject.permissions.map({ AnyHashable($0.permission) })),
databasePermissions != appPermissions
{
// Sorting order doesn't matter, but elements themselves don't match so throw error.
throw MergeError.incorrectPermissions(for: databaseObject)
}
let appVersionValues = fixedAppVersions.map { $0.version }
guard appVersionValues == sortedAppVersionsArray else {
// fixedAppVersions still doesn't match source's versions, so throw MergeError.
throw MergeError.incorrectVersionOrder(for: databaseObject)
// App versions
if let sortedAppVersions = sortedVersionsByGlobalAppID[globallyUniqueID],
let sortedAppVersionsArray = sortedAppVersions.array as? [String],
case let databaseVersions = databaseObject.versions.map({ $0.version }),
databaseVersions != sortedAppVersionsArray
{
// databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting.
let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in
let indexA = sortedAppVersions.index(of: versionA.version)
let indexB = sortedAppVersions.index(of: versionB.version)
return indexA < indexB
}
let appVersionValues = fixedAppVersions.map { $0.version }
guard appVersionValues == sortedAppVersionsArray else {
// fixedAppVersions still doesn't match source's versions, so throw MergeError.
throw MergeError.incorrectVersionOrder(for: databaseObject)
}
appVersions = fixedAppVersions
}
appVersions = fixedAppVersions
}
else
{
appVersions = databaseObject.versions
}
// Update versions post-merging to make sure latestSupportedVersion is correct.
// Always update versions post-merging to make sure latestSupportedVersion is correct.
try databaseObject.setVersions(appVersions)
}
catch

View File

@@ -146,8 +146,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
@NSManaged @objc(source) public var _source: Source?
@NSManaged public internal(set) var featuringSource: Source?
@NSManaged @objc(permissions) public var _permissions: NSOrderedSet
@NSManaged @objc(latestVersion) public private(set) var latestSupportedVersion: AppVersion?
@NSManaged @objc(versions) public private(set) var _versions: NSOrderedSet
@@ -163,9 +161,10 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
}
}
@nonobjc public var permissions: [AppPermission] {
return self._permissions.array as! [AppPermission]
@nonobjc public var permissions: Set<AppPermission> {
return self._permissions as! Set<AppPermission>
}
@NSManaged @objc(permissions) internal private(set) var _permissions: NSSet // Use NSSet to avoid eagerly fetching values.
@nonobjc public var versions: [AppVersion] {
return self._versions.array as! [AppVersion]
@@ -216,7 +215,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case platformURLs
case tintColor
case subtitle
case permissions
case permissions = "appPermissions"
case size
case isBeta = "beta"
case versions
@@ -287,7 +286,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? []
self._permissions = NSOrderedSet(array: permissions)
for permission in permissions
{
permission.appBundleID = self.bundleIdentifier
}
self._permissions = NSSet(array: permissions)
if let versions = try container.decodeIfPresent([AppVersion].self, forKey: .versions)
{
@@ -367,6 +370,23 @@ internal extension StoreApp
self._downloadURL = latestVersion.downloadURL
self._size = Int32(latestVersion.size)
}
func setPermissions(_ permissions: Set<AppPermission>)
{
for case let permission as AppPermission in self._permissions
{
if permissions.contains(permission)
{
permission.app = self
}
else
{
permission.app = nil
}
}
self._permissions = permissions as NSSet
}
}
public extension StoreApp