diff --git a/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h b/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h index 2260aa9..bd31b83 100644 --- a/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h +++ b/Sources/ITKSuperBuilder/include/ITKSuperBuilder.h @@ -1,7 +1,9 @@ -#if __APPLE__ -#import +#if !(defined(__APPLE__) && (defined(__arm64__) || defined(__x86_64__))) +#error "[InterposeKit] Supported only on Apple platforms with arm64 or x86_64 architecture." #endif +#import + NS_ASSUME_NONNULL_BEGIN /** @@ -44,7 +46,7 @@ There are a few important details: @see https://steipete.com/posts/calling-super-at-runtime/ */ -@interface ITKSuperBuilder : NSObject +@interface ITKSuperBuilder: NSObject /// Adds an empty super implementation instance method to originalClass. /// If a method already exists, this will return NO and a descriptive error message. @@ -53,22 +55,14 @@ There are a few important details: error:(NSError **)error; /// Check if the instance method in `originalClass` is a super trampoline. -+ (BOOL)isSuperTrampolineForClass:(Class)originalClass selector:(SEL)selector; - -/// x86-64 and ARM64 are currently supported. -@property(class, readonly) BOOL isSupportedArchitecture; - -#if (defined (__arm64__) || defined (__x86_64__)) && __APPLE__ -/// Helper that does not exist if architecture is not supported. -+ (BOOL)isCompileTimeSupportedArchitecture; -#endif ++ (BOOL)isSuperTrampolineForClass:(Class)originalClass + selector:(SEL)selector; @end NSString *const ITKSuperBuilderErrorDomain; typedef NS_ERROR_ENUM(ITKSuperBuilderErrorDomain, ITKSuperBuilderErrorCode) { - SuperBuilderErrorCodeArchitectureNotSupported, SuperBuilderErrorCodeNoSuperClass, SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable, SuperBuilderErrorCodeFailedToAddMethod diff --git a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m index 5f9c0d9..3869d6e 100644 --- a/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m +++ b/Sources/ITKSuperBuilder/src/ITKSuperBuilder.m @@ -1,97 +1,74 @@ -#if __APPLE__ #import "ITKSuperBuilder.h" @import ObjectiveC.message; @import ObjectiveC.runtime; -NS_ASSUME_NONNULL_BEGIN - -NSString *const ITKSuperBuilderErrorDomain = @"com.steipete.InterposeKit"; - -void msgSendSuperTrampoline(void); -void msgSendSuperStretTrampoline(void); - #define let const __auto_type #define var __auto_type -static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { - BOOL requiresStructDispatch = NO; - #if defined (__arm64__) - // ARM64 doesn't use stret dispatch. Yay! - #elif defined (__x86_64__) - // On x86-64, stret dispatch is ~used whenever return type doesn't fit into two registers - // - // http://www.sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html - // x86_64 is more complicated, including rules for returning floating-point struct fields in FPU registers, and ppc64's rules and exceptions will make your head spin. The gory details are documented in the Mac OS X ABI Guide, though as usual if the documentation and the compiler disagree then the documentation is wrong. - NSUInteger returnTypeActualSize = 0; - NSGetSizeAndAlignment(typeEncoding, &returnTypeActualSize, NULL); - requiresStructDispatch = returnTypeActualSize > (sizeof(void *) * 2); - #else - // Unknown architecture - // https://devblogs.microsoft.com/xamarin/apple-new-processor-architecture/ - // watchOS uses arm64_32 since series 4, before armv7k. watch Simulator uses i386. - // See ILP32: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dai0490a/ar01s01.html - #endif +NS_ASSUME_NONNULL_BEGIN - return requiresStructDispatch ? (IMP)msgSendSuperStretTrampoline : (IMP)msgSendSuperTrampoline; -} +NSString *const ITKSuperBuilderErrorDomain = @"com.steipete.InterposeKit"; -#define ERROR_AND_RETURN(CODE, STRING)\ -if (error) { *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain code:CODE userInfo:@{NSLocalizedDescriptionKey: STRING}];} return NO; +static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding); +static BOOL ITKMethodIsSuperTrampoline(Method method); @implementation ITKSuperBuilder -+ (BOOL)isSupportedArchitecture { -#if defined (__arm64__) || defined (__x86_64__) - return YES; -#else - return NO; -#endif -} - -#if defined (__arm64__) || defined (__x86_64__) -+ (BOOL)isCompileTimeSupportedArchitecture { - return [self isSupportedArchitecture]; -} -#endif - -+ (BOOL)isSuperTrampolineForClass:(Class)originalClass selector:(SEL)selector { - // No architecture check needed - will just be NO. - let method = class_getInstanceMethod(originalClass, selector); - return ITKMethodIsSuperTrampoline(method); -} - -+ (BOOL)addSuperInstanceMethodToClass:(Class)originalClass selector:(SEL)selector error:(NSError **)error { - if (!self.isSupportedArchitecture) { - let msg = @"Unsupported Architecture. (Support includes ARM64 and x86-64 )"; - ERROR_AND_RETURN(SuperBuilderErrorCodeArchitectureNotSupported, msg) - } - ++ (BOOL)addSuperInstanceMethodToClass:(Class)originalClass + selector:(SEL)selector + error:(NSError **)error +{ // Check that class has a superclass - let superClass = class_getSuperclass(originalClass); - if (superClass == nil) { - let msg = [NSString stringWithFormat:@"Unable to find superclass for %@", NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeNoSuperClass, msg) + let superclass = class_getSuperclass(originalClass); + + if (superclass == nil) { + if (error) { + let message = [NSString stringWithFormat:@"Unable to find superclass for %@", NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeNoSuperClass + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } - + // Fetch method called with super - let method = class_getInstanceMethod(superClass, selector); + let method = class_getInstanceMethod(superclass, selector); if (method == NULL) { - let msg = [NSString stringWithFormat:@"No dynamically dispatched method with selector %@ is available on any of the superclasses of %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable, msg) + if (error) { + let message = [NSString stringWithFormat:@"No dynamically dispatched method with selector %@ is available on any of the superclasses of %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeNoDynamicallyDispatchedMethodAvailable + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } - + // Add trampoline let typeEncoding = method_getTypeEncoding(method); let trampoline = ITKGetTrampolineForTypeEncoding(typeEncoding); let methodAdded = class_addMethod(originalClass, selector, trampoline, typeEncoding); if (!methodAdded) { - let msg = [NSString stringWithFormat:@"Failed to add method for selector %@ to class %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; - ERROR_AND_RETURN(SuperBuilderErrorCodeFailedToAddMethod, msg) + if (error) { + let message = [NSString stringWithFormat:@"Failed to add method for selector %@ to class %@", NSStringFromSelector(selector), NSStringFromClass(originalClass)]; + *error = [NSError errorWithDomain:ITKSuperBuilderErrorDomain + code:SuperBuilderErrorCodeFailedToAddMethod + userInfo:@{NSLocalizedDescriptionKey: message}]; + return NO; + } } return methodAdded; } ++ (BOOL)isSuperTrampolineForClass:(Class)originalClass + selector:(SEL)selector +{ + let method = class_getInstanceMethod(originalClass, selector); + return ITKMethodIsSuperTrampoline(method); +} + +@end + // Control if the trampoline should also push/pop the floating point registers. // This is slightly slower and not needed for our simple implementation // However, even if you just use memcpy, you will want to enable this. @@ -101,12 +78,50 @@ + (BOOL)addSuperInstanceMethodToClass:(Class)originalClass selector:(SEL)selecto // One thread local per thread should be enough _Thread_local struct objc_super _threadSuperStorage; +void msgSendSuperTrampoline(void); + +#if defined (__x86_64__) +void msgSendSuperStretTrampoline(void); +#endif + +static IMP ITKGetTrampolineForTypeEncoding(__unused const char *typeEncoding) { +#if defined (__arm64__) + // arm64 doesn't use stret dispatch. Yay! + return (IMP)msgSendSuperTrampoline; +#elif defined (__x86_64__) + // On x86_64, stret dispatch is ~used whenever return type doesn't fit into two registers + // + // http://www.sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html + // x86_64 is more complicated, including rules for returning floating-point struct fields in FPU registers, and ppc64's rules and exceptions will make your head spin. The gory details are documented in the Mac OS X ABI Guide, though as usual if the documentation and the compiler disagree then the documentation is wrong. + NSUInteger returnTypeActualSize = 0; + NSGetSizeAndAlignment(typeEncoding, &returnTypeActualSize, NULL); + BOOL requiresStructDispatch = returnTypeActualSize > (sizeof(void *) * 2); + return requiresStructDispatch ? (IMP)msgSendSuperStretTrampoline : (IMP)msgSendSuperTrampoline; +#else + // Unknown architecture + // https://devblogs.microsoft.com/xamarin/apple-new-processor-architecture/ + // watchOS uses arm64_32 since series 4, before armv7k. watch Simulator uses i386. + // See ILP32: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dai0490a/ar01s01.html + return NO; +#endif +} + static BOOL ITKMethodIsSuperTrampoline(Method method) { let methodIMP = method_getImplementation(method); - return methodIMP == (IMP)msgSendSuperTrampoline || methodIMP == (IMP)msgSendSuperStretTrampoline; + + if (methodIMP == (IMP)msgSendSuperTrampoline) { + return YES; + } + + #if defined (__x86_64__) + if (methodIMP == (IMP)msgSendSuperStretTrampoline) { + return YES; + } + #endif + + return NO; } -struct objc_super *ITKReturnThreadSuper(__unsafe_unretained id obj, SEL _cmd); struct objc_super *ITKReturnThreadSuper(__unsafe_unretained id obj, SEL _cmd) { /** Assume you have a class hierarchy made of four classes `Level1` <- `Level2` <- `Level3` <- `Level4`, @@ -123,15 +138,15 @@ static BOOL ITKMethodIsSuperTrampoline(Method method) { Looking at the method implementation we can also skip subsequent super calls. */ Class clazz = object_getClass(obj); - Class superclazz = class_getSuperclass(clazz); + Class superclass = class_getSuperclass(clazz); do { - let superclassMethod = class_getInstanceMethod(superclazz, _cmd); + let superclassMethod = class_getInstanceMethod(superclass, _cmd); let sameMethods = class_getInstanceMethod(clazz, _cmd) == superclassMethod; if (!sameMethods && !ITKMethodIsSuperTrampoline(superclassMethod)) { break; } - clazz = superclazz; - superclazz = class_getSuperclass(clazz); + clazz = superclass; + superclass = class_getSuperclass(clazz); } while (1); struct objc_super *_super = &_threadSuperStorage; @@ -140,8 +155,6 @@ static BOOL ITKMethodIsSuperTrampoline(Method method) { return _super; } -@end - /** Inline assembly is used to perfectly forward all parameters to objc_msgSendSuper, while also looking up the target on-the-fly. @@ -213,9 +226,6 @@ asm volatile ( : : : "x0", "x1"); } -// arm64 doesn't use _stret variants. -void msgSendSuperStretTrampoline(void) {} - #elif defined(__x86_64__) __attribute__((__naked__)) @@ -285,7 +295,6 @@ asm volatile ( : : : "rsi", "rdi"); } - __attribute__((__naked__)) void msgSendSuperStretTrampoline(void) { asm volatile ( @@ -332,11 +341,6 @@ asm volatile ( : : : "rsi", "rdi"); } -#else -// Unknown architecture - time to write some assembly :) -void msgSendSuperTrampoline(void) {} -void msgSendSuperStretTrampoline(void) {} #endif NS_ASSUME_NONNULL_END -#endif diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index d4d822d..bce4c9f 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -13,11 +13,39 @@ public final class Hook { build: @escaping HookBuilder ) throws { self.makeStrategy = { hook in + // Hook should never be deallocated when invoking `makeHookIMP()`, as this only + // occurs during strategy installation, which is triggered from a live hook instance. + // + // To ensure this, a strong reference cycle is intentionally created when the hook + // is applied: the strategy installs a block-based IMP (stored in `appliedHookIMP`) + // that retains a hook proxy, which in turn holds a strong reference to the hook. + // This keeps the hook alive while it is applied, allowing access to its original + // implementation. + // + // When not applied (i.e. prepared or reverted), `makeHookIMP` captures the hook + // weakly to avoid premature retention, causing the hook to be deallocated when + // the client releases it. + // + // Reference graph: + // +------------------+ + // | Client | + // +------------------+ + // | + // v + // +------------------+ +------------------+ weak + // | HookProxy |---->| Hook |< - - - - - - -+ + // +------------------+ +------------------+ | + // ^ | | + // | v | + // | +------------------+ +------------------+ + // | | HookStrategy |---->| makeHookIMP | + // | +------------------+ +------------------+ + // | | + // | v + // | +------------------+ + // +--------------| appliedHookIMP | + // +------------------+ let makeHookIMP: () -> IMP = { [weak hook] in - - // Hook should never be deallocated when invoking `makeHookIMP()`, as this only - // happens when installing implementation from within the strategy, which is - // triggered from a live hook instance. guard let hook else { Interpose.fail( """ @@ -92,27 +120,37 @@ public final class Hook { /// Applies the hook by interposing the method implementation. public func apply() throws { - guard self.state == .pending else { return } - - do { - try self.strategy.replaceImplementation() - self.state = .active - } catch { - self.state = .failed - throw error + switch self.state { + case .pending: + do { + try self.strategy.replaceImplementation() + self.state = .active + } catch { + self.state = .failed + throw error + } + case .failed: + throw InterposeError.hookInFailedState + case .active: + return } } /// Reverts the hook, restoring the original method implementation. public func revert() throws { - guard self.state == .active else { return } - - do { - try self.strategy.restoreImplementation() - self.state = .pending - } catch { - self.state = .failed - throw error + switch self.state { + case .active: + do { + try self.strategy.restoreImplementation() + self.state = .pending + } catch { + self.state = .failed + throw error + } + case .failed: + throw InterposeError.hookInFailedState + case .pending: + return } } @@ -149,24 +187,27 @@ public final class Hook { // ============================================================================ // deinit { - var logComponents = [String]() - - switch self.state { - case .pending: - logComponents.append("Releasing") - case .active: - logComponents.append("Keeping") - case .failed: - logComponents.append("Leaking") - } - - logComponents.append("-[\(self.class) \(self.selector)]") - - if let hookIMP = self.strategy.appliedHookIMP { - logComponents.append("IMP: \(hookIMP)") - } - - Interpose.log(logComponents.joined(separator: " ")) + Interpose.log({ + var components = [String]() + + switch self.state { + case .pending: + components.append("Releasing") + case .active: + components.append("Keeping") + case .failed: + components.append("Leaking") + } + + components.append("hook for") + components.append("-[\(self.class) \(self.selector)]") + + if let hookIMP = self.strategy.appliedHookIMP { + components.append("IMP: \(hookIMP)") + } + + return components.joined(separator: " ") + }()) } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index ee53f49..8acb4a8 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -1,4 +1,4 @@ -import Foundation +import ObjectiveC internal final class ClassHookStrategy: HookStrategy { @@ -23,6 +23,7 @@ internal final class ClassHookStrategy: HookStrategy { internal let `class`: AnyClass internal var scope: HookScope { .class } internal let selector: Selector + private let makeHookIMP: () -> IMP // ============================================================================ // @@ -37,6 +38,7 @@ internal final class ClassHookStrategy: HookStrategy { // ============================================================================ // internal func validate() throws { + // Ensure that the method exists. guard class_getInstanceMethod(self.class, self.selector) != nil else { throw InterposeError.methodNotFound( class: self.class, @@ -44,6 +46,7 @@ internal final class ClassHookStrategy: HookStrategy { ) } + // Ensure that the class directly implements the method. guard class_implementsInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotDirectlyImplemented( class: self.class, @@ -60,6 +63,8 @@ internal final class ClassHookStrategy: HookStrategy { let hookIMP = self.makeHookIMP() guard let method = class_getInstanceMethod(self.class, self.selector) else { + // This should not happen under normal circumstances, as we perform validation upon + // creating the hook strategy, which itself checks for the presence of the method. throw InterposeError.methodNotFound( class: self.class, selector: self.selector @@ -72,6 +77,8 @@ internal final class ClassHookStrategy: HookStrategy { hookIMP, method_getTypeEncoding(method) ) else { + // This should not happen under normal circumstances, as we perform validation upon + // creating the hook strategy, which checks if the class directly implements the method. throw InterposeError.implementationNotFound( class: self.class, selector: self.selector @@ -81,7 +88,7 @@ internal final class ClassHookStrategy: HookStrategy { self.appliedHookIMP = hookIMP self.storedOriginalIMP = originalIMP - Interpose.log("Swizzled -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") } internal func restoreImplementation() throws { @@ -116,7 +123,7 @@ internal final class ClassHookStrategy: HookStrategy { ) } - Interpose.log("Restored -[\(self.class) \(self.selector)] IMP: \(originalIMP)") + Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift deleted file mode 100644 index 95facf8..0000000 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import ITKSuperBuilder - -class InterposeSubclass { - - private enum Constants { - static let subclassSuffix = "InterposeKit_" - } - - /// The object that is being hooked. - let object: AnyObject - - /// Subclass that we create on the fly - private(set) var dynamicClass: AnyClass - - /// If the class has been altered (e.g. via NSKVONotifying_ KVO logic) - /// then perceived and actual class don't match. - /// - /// Making KVO and Object-based hooking work at the same time is difficult. - /// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. - init(object: AnyObject) throws { - self.object = object - self.dynamicClass = try Self.getExistingSubclass(object: object) ?? Self.createSubclass(object: object) - } - - private static func createSubclass(object: AnyObject) throws -> AnyClass { - let perceivedClass: AnyClass = type(of: object) - let actualClass: AnyClass = object_getClass(object)! - - let className = NSStringFromClass(perceivedClass) - // Right now we are wasteful. Might be able to optimize for shared IMP? - let uuid = UUID().uuidString.replacingOccurrences(of: "-", with: "") - let subclassName = Constants.subclassSuffix + className + uuid - - let subclass: AnyClass? = subclassName.withCString { cString in - // swiftlint:disable:next force_cast - if let existingClass = objc_getClass(cString) as! AnyClass? { - return existingClass - } else { - guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { return nil } - class_setPerceivedClass(for: subclass, to: perceivedClass) - objc_registerClassPair(subclass) - return subclass - } - } - - guard let nnSubclass = subclass else { - throw InterposeError.failedToAllocateClassPair(class: perceivedClass, subclassName: subclassName) - } - - object_setClass(object, nnSubclass) - let oldName = NSStringFromClass(class_getSuperclass(object_getClass(object)!)!) - Interpose.log("Generated \(NSStringFromClass(nnSubclass)) for object (was: \(oldName))") - return nnSubclass - } - - /// We need to reuse a dynamic subclass if the object already has one. - private static func getExistingSubclass(object: AnyObject) -> AnyClass? { - let actualClass: AnyClass = object_getClass(object)! - if NSStringFromClass(actualClass).hasPrefix(Constants.subclassSuffix) { - return actualClass - } - return nil - } - - class var supportsSuperTrampolines: Bool { - ITKSuperBuilder.isSupportedArchitecture - } - - func addSuperTrampoline(selector: Selector) { - do { - try ITKSuperBuilder.addSuperInstanceMethod(to: dynamicClass, selector: selector) - - let imp = class_getMethodImplementation(dynamicClass, selector)! - Interpose.log("Added super for -[\(dynamicClass).\(selector)]: \(imp)") - } catch { - Interpose.log("Failed to add super implementation to -[\(dynamicClass).\(selector)]: \(error)") - } - } -} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift index e32176c..15385a1 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift @@ -13,7 +13,7 @@ internal enum ObjectHookRegistry { objc_setAssociatedObject( block, - &self.associatedKey, + &ObjectHookRegistryKey, WeakReference(handle), .OBJC_ASSOCIATION_RETAIN ) @@ -26,16 +26,16 @@ internal enum ObjectHookRegistry { guard let reference = objc_getAssociatedObject( block, - &self.associatedKey + &ObjectHookRegistryKey ) as? WeakReference else { return nil } return reference.object } - private static var associatedKey: UInt8 = 0 - } +fileprivate var ObjectHookRegistryKey: UInt8 = 0 + fileprivate class WeakReference: NSObject { fileprivate init(_ object: T?) { diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 1c2e3e3..81eef05 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -1,8 +1,13 @@ -import Foundation +import ITKSuperBuilder +import ObjectiveC -final class ObjectHookStrategy: HookStrategy { +internal final class ObjectHookStrategy: HookStrategy { - init( + // ============================================================================ // + // MARK: Initialization + // ============================================================================ // + + internal init( object: NSObject, selector: Selector, makeHookIMP: @escaping () -> IMP @@ -13,174 +18,205 @@ final class ObjectHookStrategy: HookStrategy { self.makeHookIMP = makeHookIMP } - let `class`: AnyClass - let object: NSObject - var scope: HookScope { .object(self.object) } - let selector: Selector + // ============================================================================ // + // MARK: Configuration + // ============================================================================ // + + internal let `class`: AnyClass + internal let object: NSObject + internal var scope: HookScope { .object(self.object) } + internal let selector: Selector private let makeHookIMP: () -> IMP - private(set) var appliedHookIMP: IMP? - private(set) var storedOriginalIMP: IMP? + + // ============================================================================ // + // MARK: Implementations & Handle + // ============================================================================ // + + private(set) internal var appliedHookIMP: IMP? + private(set) internal var storedOriginalIMP: IMP? private lazy var handle = ObjectHookHandle( getOriginalIMP: { [weak self] in self?.storedOriginalIMP }, setOriginalIMP: { [weak self] in self?.storedOriginalIMP = $0 } ) + + // ============================================================================ // + // MARK: Validation + // ============================================================================ // - /// Subclass that we create on the fly - var interposeSubclass: InterposeSubclass? - - // Logic switch to use super builder - let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines - - var dynamicSubclass: AnyClass { - interposeSubclass!.dynamicClass - } - - func validate() throws { + internal func validate() throws { + // Ensure that the method exists. guard class_getInstanceMethod(self.class, self.selector) != nil else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) + throw InterposeError.methodNotFound( + class: self.class, + selector: self.selector + ) } - if let _ = checkObjectPosingAsDifferentClass(self.object) { + // Ensure that the method has an associated implementation (can be in a superclass). + guard self.lookUpIMP() != nil else { + throw InterposeError.implementationNotFound( + class: self.class, + selector: self.selector + ) + } + + // Ensure that the object either does not have a dynamic subclass installed or that + // it is the subclass installed by InterposeKit rather than by KVO or other mechanism. + let perceivedClass: AnyClass = type(of: self.object) + let actualClass: AnyClass = object_getClass(self.object) + + if perceivedClass != actualClass { if object_isKVOActive(self.object) { - throw InterposeError.kvoDetected(object) + throw InterposeError.kvoDetected(object: self.object) + } + + if !ObjectSubclassManager.hasInstalledSubclass(self.object) { + throw InterposeError.unexpectedDynamicSubclass( + object: self.object, + actualClass: actualClass + ) } - // TODO: Handle the case where the object is posing as different class but not the interpose subclass } } - func replaceImplementation() throws { + // ============================================================================ // + // MARK: Installing Implementation + // ============================================================================ // + + internal func replaceImplementation() throws { let hookIMP = self.makeHookIMP() - self.appliedHookIMP = hookIMP - ObjectHookRegistry.register(self.handle, for: hookIMP) + // Fetch the method, whose implementation we want to replace. guard let method = class_getInstanceMethod(self.class, self.selector) else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) - } - - // The implementation of the call that is hooked must exist. - guard self.lookUpIMP() != nil else { - throw InterposeError.implementationNotFound( + throw InterposeError.methodNotFound( class: self.class, selector: self.selector ) } - // Check if there's an existing subclass we can reuse. - // Create one at runtime if there is none. - self.interposeSubclass = try InterposeSubclass(object: self.object) - - // This function searches superclasses for implementations - let classImplementsMethod = class_implementsInstanceMethod(self.dynamicSubclass, self.selector) - let encoding = method_getTypeEncoding(method) + // Retrieve a ready-to-use dynamic subclass. It might be reused if the object already + // has one installed or a newly created one. + let subclass: AnyClass = try ObjectSubclassManager.ensureSubclassInstalled(for: self.object) - if self.generatesSuperIMP { - // If the subclass is empty, we create a super trampoline first. - // If a hook already exists, we must skip this. - if !classImplementsMethod { - // TODO: Make this failable - self.interposeSubclass!.addSuperTrampoline(selector: self.selector) - } - - // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - guard self.storedOriginalIMP != nil else { - // This should not happen if the class implements the method or we have installed - // the super trampoline. Instead, we should make the trampoline implementation - // failable. - throw InterposeError.implementationNotFound( - class: self.dynamicSubclass, + // If the dynamic subclass does not implement the method directly, we create a super + // trampoline first. Otherwise, when a hook for that method has already been applied + // (and potentially reverted), we skip this step. + if !class_implementsInstanceMethod(subclass, self.selector) { + do { + try ITKSuperBuilder.addSuperInstanceMethod( + to: subclass, selector: self.selector ) + + Interpose.log({ + var message = "Added super trampoline for -[\(subclass) \(self.selector)]" + if let imp = class_getMethodImplementation(subclass, self.selector) { + message += " IMP: \(imp)" + } + return message + }()) + } catch { + throw InterposeError.failedToAddSuperTrampoline( + class: subclass, + selector: self.selector, + underlyingError: error as NSError + ) } - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") - } else { - // Could potentially be unified in the code paths - if classImplementsMethod { - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - if self.storedOriginalIMP != nil { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP) via replacement") - } else { - Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - throw InterposeError.unableToAddMethod(self.class, self.selector) - } - } else { - let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) - if didAddMethod { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - } else { - Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") - throw InterposeError.unableToAddMethod(self.class, self.selector) - } - } } + + guard let originalIMP = class_replaceMethod( + subclass, + self.selector, + hookIMP, + method_getTypeEncoding(method) + ) else { + // This should not fail under normal circumstances, as the subclass should already + // have an associated implementation, which might be the just-installed trampoline + // or an existing hook. + throw InterposeError.implementationNotFound( + class: subclass, + selector: self.selector + ) + } + + self.appliedHookIMP = hookIMP + self.storedOriginalIMP = originalIMP + + self.object.incrementHookCount() + ObjectHookRegistry.register(self.handle, for: hookIMP) + + Interpose.log("Replaced implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") } - func restoreImplementation() throws { + internal func restoreImplementation() throws { + if object_isKVOActive(self.object) { + throw InterposeError.kvoDetected(object: self.object) + } + guard let hookIMP = self.appliedHookIMP else { return } + guard let originalIMP = self.storedOriginalIMP else { return } + defer { imp_removeBlock(hookIMP) self.appliedHookIMP = nil + self.storedOriginalIMP = nil } - guard let method = class_getInstanceMethod(self.class, self.selector) else { - throw InterposeError.methodNotFound(class: self.class, selector: self.selector) - } + guard let dynamicSubclass = ObjectSubclassManager.installedSubclass( + for: self.object + ) else { return } - guard self.storedOriginalIMP != nil else { - // Removing methods at runtime is not supported. - // https://stackoverflow.com/questions/1315169/how-do-i-remove-instance-methods-at-runtime-in-objective-c-2-0 - // - // This codepath will be hit if the super helper is missing. - // We could recreate the whole class at runtime and rebuild all hooks, - // but that seems excessive when we have a trampoline at our disposal. - Interpose.log("Reset of -[\(self.class).\(self.selector)] not supported. No IMP") - throw InterposeError.resetUnsupported("No Original IMP found. SuperBuilder missing?") + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound( + class: self.class, + selector: self.selector + ) } - guard let currentIMP = class_getMethodImplementation(self.dynamicSubclass, self.selector) else { - throw InterposeError.unknownError("No Implementation found") + guard let currentIMP = class_getMethodImplementation(dynamicSubclass, self.selector) else { + throw InterposeError.implementationNotFound( + class: self.class, + selector: self.selector + ) } - // We are the topmost hook, replace method. + // If we are the topmost hook, we have to replace the implementation on the subclass. if currentIMP == hookIMP { - let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) + let previousIMP = class_replaceMethod( + dynamicSubclass, + self.selector, + originalIMP, + method_getTypeEncoding(method) + ) + guard previousIMP == hookIMP else { throw InterposeError.revertCorrupted( - class: self.dynamicSubclass, + class: dynamicSubclass, selector: self.selector, imp: previousIMP ) } - Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") } else { + // Otherwise, find the next hook and set its original IMP to this hook’s original IMP, + // effectively unlinking this hook from the chain. let nextHook = self._findParentHook(from: currentIMP) - // Replace next's original IMP - nextHook?.originalIMP = self.storedOriginalIMP + nextHook?.originalIMP = originalIMP } - self.storedOriginalIMP = nil + Interpose.log("Restored implementation for -[\(self.class) \(self.selector)] IMP: \(originalIMP)") - // FUTURE: remove class pair! - // This might fail if we get KVO observed. - // objc_disposeClassPair does not return a bool but logs if it fails. - // - // objc_disposeClassPair(dynamicSubclass) - // self.dynamicSubclass = nil - } - - // Checks if a object is posing as a different class - // via implementing 'class' and returning something else. - private func checkObjectPosingAsDifferentClass(_ object: AnyObject) -> AnyClass? { - let perceivedClass: AnyClass = type(of: object) - let actualClass: AnyClass = object_getClass(object)! - if actualClass != perceivedClass { - return actualClass + // Decrement the hook count and if this was the last hook, uninstall the dynamic subclass. + if self.object.decrementHookCount() { + ObjectSubclassManager.uninstallSubclass(for: object) } - return nil } + // ============================================================================ // + // MARK: Helpers + // ============================================================================ // + /// Traverses the object hook chain to find the handle to the parent of this hook, starting /// from the topmost IMP for the hooked method. /// @@ -208,3 +244,54 @@ final class ObjectHookStrategy: HookStrategy { } } + +extension NSObject { + + /// Increments the number of active object-based hooks on this instance by one. + fileprivate func incrementHookCount() { + self.hookCount += 1 + } + + /// Decrements the number of active object-based hooks on this instance by one and returns + /// `true` if this was the last hook, or `false` otherwise. + fileprivate func decrementHookCount() -> Bool { + guard self.hookCount > 0 else { return false } + self.hookCount -= 1 + return self.hookCount == 0 + } + + /// The current number of active object-based hooks on this instance. + /// + /// Internally stored using associated objects. Always returns a non-negative value. + private var hookCount: Int { + get { + guard let count = objc_getAssociatedObject( + self, + &ObjectHookCountKey + ) as? NSNumber else { return 0 } + + return count.intValue + } + set { + let newCount = max(0, newValue) + if newCount == 0 { + objc_setAssociatedObject( + self, + &ObjectHookCountKey, + nil, + .OBJC_ASSOCIATION_ASSIGN + ) + } else { + objc_setAssociatedObject( + self, + &ObjectHookCountKey, + NSNumber(value: newCount), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + } + +} + +private var ObjectHookCountKey: UInt8 = 0 diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift new file mode 100644 index 0000000..52a4ed2 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift @@ -0,0 +1,156 @@ +import Foundation + +internal enum ObjectSubclassManager { + + // ============================================================================ // + // MARK: Getting Installed Subclass + // ============================================================================ // + + internal static func installedSubclass( + for object: NSObject + ) -> AnyClass? { + let actualClass: AnyClass = object_getClass(object) + return self.isDynamicSubclass(actualClass) ? actualClass : nil + } + + internal static func hasInstalledSubclass(_ object: NSObject) -> Bool { + let actualClass: AnyClass = object_getClass(object) + return self.isDynamicSubclass(actualClass) + } + + private static func isDynamicSubclass(_ class: AnyClass) -> Bool { + NSStringFromClass(`class`).hasPrefix(self.namePrefix) + } + + // ============================================================================ // + // MARK: Installing & Uninstalling + // ============================================================================ // + + internal static func ensureSubclassInstalled( + for object: NSObject + ) throws -> AnyClass { + // If there is a dynamic subclass already installed on the object, reuse it straightaway. + if let installedSubclass = self.installedSubclass(for: object) { + Interpose.log({ + let subclassName = NSStringFromClass(installedSubclass) + let objectAddress = String(format: "%p", object) + return "Reused subclass: \(subclassName) for object \(objectAddress)" + }()) + + return installedSubclass + } + + // Otherwise, create a dynamic subclass by generating a unique name and registering it + // with the runtime. + let subclass: AnyClass = try self.makeSubclass(for: object) + + // Finally, set the created class on the object. + let previousClass: AnyClass? = object_setClass(object, subclass) + + Interpose.log({ + let subclassName = NSStringFromClass(subclass) + let objectAddress = String(format: "%p", object) + var message = "Created subclass: \(subclassName) for object \(objectAddress)" + + if let previousClass { + message += " (previously: \(NSStringFromClass(previousClass)))" + } + + return message + }()) + + return subclass + } + + internal static func uninstallSubclass( + for object: NSObject + ) { + // Get the InterposeKit-managed dynamic subclass installed on the object. + guard let dynamicSubclass = self.installedSubclass(for: object) else { return } + + // Retrieve the original class (superclass of the dynamic subclass) we want to restore + // the object to. + guard let originalClass = class_getSuperclass(dynamicSubclass) else { return } + + // Restore the object’s class to its original class. + object_setClass(object, originalClass) + + Interpose.log({ + let subclassName = NSStringFromClass(dynamicSubclass) + let originalClassName = NSStringFromClass(originalClass) + let objectAddress = String(format: "%p", object) + return "Removed subclass: \(subclassName), restored \(originalClassName) on object \(objectAddress)" + }()) + + // Dispose of the dynamic subclass. + // + // This is safe to call here because all hooks have been reverted. Unfortunately, we can’t + // validate this explicitly, as `objc_disposeClassPair(...)` offers no feedback mechanism + // and will silently fail if the subclass is still in use. + objc_disposeClassPair(dynamicSubclass) + } + + // ============================================================================ // + // MARK: Subclass Generation + // ============================================================================ // + + private static func makeSubclass( + for object: NSObject + ) throws -> AnyClass { + let actualClass: AnyClass = object_getClass(object) + let perceivedClass: AnyClass = type(of: object) + + let subclassName = self.uniqueSubclassName(for: perceivedClass) + + return try subclassName.withCString { cString in + // Attempt to allocate a new subclass that inherits from the object’s actual class. + guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { + throw InterposeError.subclassCreationFailed( + subclassName: subclassName, + object: object + ) + } + + // Set the perceived class to make the runtime report the original type. + class_setPerceivedClass(for: subclass, to: perceivedClass) + + // Register the subclass with the runtime. + objc_registerClassPair(subclass) + + return subclass + } + } + + /// Constructs a unique subclass name for the given perceived class. + /// + /// Subclass names must be globally unique to avoid registration conflicts. Earlier versions + /// used random UUIDs, which guaranteed uniqueness but resulted in long and noisy names. + /// We then considered using the object’s memory address, but since addresses can be reused + /// during the lifetime of a process, this led to potential conflicts and flaky test behavior. + /// + /// The final approach uses a global incrementing counter to ensure uniqueness without relying + /// on randomness or memory layout. This results in shorter, more readable names that are safe + /// across repeated test runs and stable in production. + private static func uniqueSubclassName( + for perceivedClass: AnyClass + ) -> String { + let className = NSStringFromClass(perceivedClass) + + let counterSuffix: String = self.subclassCounterQueue.sync { + self.subclassCounter &+= 1 + return String(format: "%04llx", self.subclassCounter) + } + + return "\(self.namePrefix)_\(className)_\(counterSuffix)" + } + + /// The prefix used for all dynamically created subclass names. + private static let namePrefix = "InterposeKit" + + /// A global counter for generating unique subclass name suffixes. + private static var subclassCounter: UInt64 = 0 + + /// A serial queue to ensure thread-safe access to `subclassCounter`. + private static let subclassCounterQueue = DispatchQueue(label: "com.steipete.InterposeKit.subclassCounter") + +} diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index ca02b73..fa6f298 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -1,3 +1,7 @@ +#if !(arch(arm64) || arch(x86_64)) +#error("[InterposeKit] This code only supports arm64 and x86_64 architectures.") +#endif + import ObjectiveC /// Interpose is a modern library to swizzle elegantly in Swift. @@ -84,14 +88,18 @@ public enum Interpose { /// The flag indicating whether logging is enabled. public static var isLoggingEnabled = false - internal static func log(_ message: String) { + internal static func log( + _ message: @autoclosure () -> String + ) { if self.isLoggingEnabled { - print("[InterposeKit] \(message)") + print("[InterposeKit] \(message())") } } - internal static func fail(_ message: String) -> Never { - fatalError("[InterposeKit] \(message)") + internal static func fail( + _ message: @autoclosure () -> String + ) -> Never { + fatalError("[InterposeKit] \(message())") } } diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 384dd01..2b55410 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -1,6 +1,9 @@ import Foundation -public enum InterposeError: LocalizedError { +public enum InterposeError: Error { + + /// A hook operation failed and the hook is no longer usable. + case hookInFailedState /// No instance method found for the selector on the specified class. /// @@ -44,66 +47,135 @@ public enum InterposeError: LocalizedError { selector: Selector, imp: IMP? ) - - /// Unable to register subclass for object-based interposing. - case failedToAllocateClassPair(class: AnyClass, subclassName: String) - - /// Unable to add method for object-based interposing. - case unableToAddMethod(AnyClass, Selector) - - /// Object-based hooking does not work if an object is using KVO. - /// The KVO mechanism also uses subclasses created at runtime but doesn't check for additional overrides. - /// Adding a hook eventually crashes the KVO management code so we reject hooking altogether in this case. - case kvoDetected(AnyObject) - - /// Object is lying about it's actual class metadata. - /// This usually happens when other swizzling libraries (like Aspects) also interfere with a class. - /// While this might just work, it's not worth risking a crash, so similar to KVO this case is rejected. + + /// Failed to create a dynamic subclass for the given object. /// - /// @note Printing classes in Swift uses the class posing mechanism. - /// Use `NSClassFromString` to get the correct name. - case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass) - - /// Unable to remove hook. - case resetUnsupported(_ reason: String) + /// This can occur if the desired subclass name is already in use. While InterposeKit + /// generates globally unique subclass names using an internal counter, a name collision may + /// still happen if another system has registered a class with the same name earlier during + /// the process lifetime. + case subclassCreationFailed( + subclassName: String, + object: NSObject + ) + + /// Detected Key-Value Observing on the object while applying or reverting a hook. + /// + /// The KVO mechanism installs its own dynamic subclass at runtime but does not support + /// additional method overrides. Applying or reverting hooks on an object under KVO can lead + /// to crashes in the Objective-C runtime, so such operations are explicitly disallowed. + /// + /// It is safe to start observing an object *after* it has been hooked, but not the other way + /// around. Once KVO is active, reverting an existing hook is also considered unsafe. + case kvoDetected(object: NSObject) + + /// The object uses a dynamic subclass that was not installed by InterposeKit. + /// + /// This typically indicates interference from another runtime system, such as method + /// swizzling libraries (like [Aspects](https://github.com/steipete/Aspects)). Similar to KVO, + /// such subclasses bypass normal safety checks. Hooking is disallowed in this case to + /// avoid crashes. + /// + /// - Note: Use `NSStringFromClass` to print class names accurately. Swift’s default + /// formatting may reflect the perceived, not the actual runtime class. + case unexpectedDynamicSubclass( + object: NSObject, + actualClass: AnyClass + ) + + /// Failed to add a super trampoline for the specified class and selector. + /// + /// When interposing an instance method on a dynamic subclass, InterposeKit installs + /// a *super trampoline*—a method that forwards calls to the original implementation + /// in the superclass. This allows the hook to delegate to the original behavior when needed. + /// + /// This error is thrown when the trampoline cannot be added, which is very rare. + /// Refer to the underlying error for more details. + case failedToAddSuperTrampoline( + class: AnyClass, + selector: Selector, + underlyingError: NSError + ) - /// Generic failure - case unknownError(_ reason: String) } extension InterposeError: Equatable { - // Lazy equating via string compare - public static func == (lhs: InterposeError, rhs: InterposeError) -> Bool { - return lhs.errorDescription == rhs.errorDescription - } - - public var errorDescription: String { - switch self { - case .methodNotFound(let klass, let selector): - return "Method not found: -[\(klass) \(selector)]" - case .methodNotDirectlyImplemented(let klass, let selector): - return "Method not directly implemented: -[\(klass) \(selector)]" - case .implementationNotFound(let klass, let selector): - return "Implementation not found: -[\(klass) \(selector)]" - case .revertCorrupted(let klass, let selector, let IMP): - return "Unexpected Implementation in -[\(klass) \(selector)]: \(String(describing: IMP))" - case .failedToAllocateClassPair(let klass, let subclassName): - return "Failed to allocate class pair: \(klass), \(subclassName)" - case .unableToAddMethod(let klass, let selector): - return "Unable to add method: -[\(klass) \(selector)]" - case .kvoDetected(let obj): - return "Unable to hook object that uses Key Value Observing: \(obj)" - case .objectPosingAsDifferentClass(let obj, let actualClass): - return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/" - case .resetUnsupported(let reason): - return "Reset Unsupported: \(reason)" - case .unknownError(let reason): - return reason + public static func == (lhs: Self, rhs: Self) -> Bool { + switch lhs { + case let .methodNotFound(lhsClass, lhsSelector): + switch rhs { + case let .methodNotFound(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .methodNotDirectlyImplemented(lhsClass, lhsSelector): + switch rhs { + case let .methodNotDirectlyImplemented(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .implementationNotFound(lhsClass, lhsSelector): + switch rhs { + case let .implementationNotFound(rhsClass, rhsSelector): + return lhsClass == rhsClass && lhsSelector == rhsSelector + default: + return false + } + + case let .revertCorrupted(lhsClass, lhsSelector, lhsIMP): + switch rhs { + case let .revertCorrupted(rhsClass, rhsSelector, rhsIMP): + return lhsClass == rhsClass && lhsSelector == rhsSelector && lhsIMP == rhsIMP + default: + return false + } + + case let .subclassCreationFailed(lhsName, lhsObject): + switch rhs { + case let .subclassCreationFailed(rhsName, rhsObject): + return lhsName == rhsName && lhsObject === rhsObject + default: + return false + } + + case let .kvoDetected(lhsObject): + switch rhs { + case let .kvoDetected(rhsObject): + return lhsObject === rhsObject + default: + return false + } + + case let .unexpectedDynamicSubclass(lhsObject, lhsClass): + switch rhs { + case let .unexpectedDynamicSubclass(rhsObject, rhsClass): + return lhsObject === rhsObject && lhsClass == rhsClass + default: + return false + } + + case let .failedToAddSuperTrampoline(lhsClass, lhsSelector, lhsError): + switch rhs { + case let .failedToAddSuperTrampoline(rhsClass, rhsSelector, rhsError): + return lhsClass == rhsClass + && lhsSelector == rhsSelector + && lhsError.domain == rhsError.domain + && lhsError.code == rhsError.code + default: + return false + } + + case .hookInFailedState: + switch rhs { + case .hookInFailedState: + return true + default: + return false + } } } - - @discardableResult func log() -> InterposeError { - Interpose.log(self.errorDescription) - return self - } } diff --git a/Sources/InterposeKit/Utilities/object_getClass.swift b/Sources/InterposeKit/Utilities/object_getClass.swift new file mode 100644 index 0000000..cf7ae7c --- /dev/null +++ b/Sources/InterposeKit/Utilities/object_getClass.swift @@ -0,0 +1,13 @@ +import ObjectiveC + +/// Returns the class of an object. +/// +/// - Parameter object: A non-nil object to inspect. +/// - Returns: The class of which `object` is an instance. +internal func object_getClass(_ object: AnyObject) -> AnyClass { + if let `class` = ObjectiveC.object_getClass(object as Any?) { + return `class` + } else { + fatalError("Expected object_getClass(…) to return a class for non-nil object \(object).") + } +} diff --git a/Tests/InterposeKitTests/ClassHookTests.swift b/Tests/InterposeKitTests/ClassHookTests.swift index f5c94f8..f00d3a8 100644 --- a/Tests/InterposeKitTests/ClassHookTests.swift +++ b/Tests/InterposeKitTests/ClassHookTests.swift @@ -293,6 +293,11 @@ final class ClassHookTests: XCTestCase { hook.debugDescription, #"^Failed hook for -\[ExampleClass doSomething\]$"# ) + + XCTAssertThrowsError( + try hook.revert(), + expected: InterposeError.hookInFailedState + ) } func testCleanUp_implementationPreserved() throws { diff --git a/Tests/InterposeKitTests/ObjectHookTests.swift b/Tests/InterposeKitTests/ObjectHookTests.swift index 7af60d6..e9d4367 100644 --- a/Tests/InterposeKitTests/ObjectHookTests.swift +++ b/Tests/InterposeKitTests/ObjectHookTests.swift @@ -1,9 +1,10 @@ -import InterposeKit +@testable import InterposeKit import XCTest fileprivate class ExampleClass: NSObject { @objc dynamic var intValue = 1 @objc dynamic func doSomething() {} + @objc dynamic func doSomethingElse() {} @objc dynamic var arrayValue: [String] { ["base"] } } @@ -48,7 +49,7 @@ final class ObjectHookTests: XCTestCase { ) } - func testMultipleHooks() throws { + func testMultipleHooksOnSingleObject() throws { let object = ExampleClass() XCTAssertEqual(object.arrayValue, ["base"]) @@ -82,6 +83,56 @@ final class ObjectHookTests: XCTestCase { XCTAssertEqual(object.arrayValue, ["base"]) } + func testHooksOnMultipleObjects() throws { + let object1 = ExampleClass() + let object2 = ExampleClass() + + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + + XCTAssertEqual( + NSStringFromClass(object_getClass(object1)), + NSStringFromClass(object_getClass(object2)) + ) + + let hook1 = try object1.applyHook( + for: #selector(getter: ExampleClass.arrayValue), + methodSignature: (@convention(c) (NSObject, Selector) -> [String]).self, + hookSignature: (@convention(block) (NSObject) -> [String]).self + ) { hook in + return { `self` in + return hook.original(self, hook.selector) + ["hook1"] + } + } + XCTAssertEqual(object1.arrayValue, ["base", "hook1"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + + let hook2 = try object2.applyHook( + for: #selector(getter: ExampleClass.arrayValue), + methodSignature: (@convention(c) (NSObject, Selector) -> [String]).self, + hookSignature: (@convention(block) (NSObject) -> [String]).self + ) { hook in + return { `self` in + return hook.original(self, hook.selector) + ["hook2"] + } + } + XCTAssertEqual(object1.arrayValue, ["base", "hook1"]) + XCTAssertEqual(object2.arrayValue, ["base", "hook2"]) + + XCTAssertNotEqual( + NSStringFromClass(object_getClass(object1)), + NSStringFromClass(object_getClass(object2)) + ) + + try hook1.revert() + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base", "hook2"]) + + try hook2.revert() + XCTAssertEqual(object1.arrayValue, ["base"]) + XCTAssertEqual(object2.arrayValue, ["base"]) + } + // Hooking fails on an object that has KVO activated. func testKVO_observationBeforeHooking() throws { let object = ExampleClass() @@ -108,14 +159,17 @@ final class ObjectHookTests: XCTestCase { hook.original(self, hook.selector) + 1 } }, - expected: InterposeError.kvoDetected(object) + expected: InterposeError.kvoDetected(object: object) ) XCTAssertEqual(object.intValue, 2) _ = token } - // KVO works just fine on an object that has already been hooked. + // KVO works on an object that was hooked earlier, but reverting the hook while an active + // observer is installed fails. While it is technically possible to recover by stopping + // the observation before reverting, the hook enters a failed state on error, preventing + // it from accepting further operations. func testKVO_observationAfterHooking() throws { let object = ExampleClass() @@ -148,10 +202,15 @@ final class ObjectHookTests: XCTestCase { XCTAssertEqual(object.intValue, 3) XCTAssertEqual(didInvokeObserver, true) + XCTAssertThrowsError( + try hook.revert(), + expected: InterposeError.kvoDetected(object: object) + ) + _ = token } - func testCleanUp_implementationPreserved() throws { + func testCleanUp_hook_implementationPreserved() throws { let object = ExampleClass() var deallocated = false @@ -173,7 +232,7 @@ final class ObjectHookTests: XCTestCase { XCTAssertFalse(deallocated) } - func testCleanUp_implementationDeallocated() throws { + func testCleanUp_hook_implementationDeallocated() throws { let object = ExampleClass() var deallocated = false @@ -196,5 +255,56 @@ final class ObjectHookTests: XCTestCase { XCTAssertTrue(deallocated) } + + func testCleanUp_dynamicSubclass() throws { + let object = ExampleClass() + + // Original class + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + + let hook1 = try object.applyHook( + for: #selector(ExampleClass.doSomething), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + let hook2 = try object.applyHook( + for: #selector(ExampleClass.doSomething), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + let hook3 = try object.applyHook( + for: #selector(ExampleClass.doSomethingElse), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in hook.original(self, hook.selector) } + } + + // Dynamic subclass + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + try hook1.revert() + try hook2.revert() + + // Dynamic subclass + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + // Back to original subclass after reverting the last hook + try hook3.revert() + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + + try hook2.apply() + XCTAssertTrue(object_getClass(object) != ExampleClass.self) + + // Back to original subclass after reverting the last hook + try hook2.revert() + XCTAssertTrue(object_getClass(object) == ExampleClass.self) + } } diff --git a/Tests/InterposeKitTests/UtilitiesTests.swift b/Tests/InterposeKitTests/UtilitiesTests.swift index ca85722..1fcaae1 100644 --- a/Tests/InterposeKitTests/UtilitiesTests.swift +++ b/Tests/InterposeKitTests/UtilitiesTests.swift @@ -20,7 +20,13 @@ extension NSObject { final class UtilitiesTests: XCTestCase { - func test_setPerceivedClass() { + static var hasRunTestSetPerceivedClass = false + + func test_setPerceivedClass() throws { + // Runs only once to avoid leaking class swizzling across test runs. + try XCTSkipIf(Self.hasRunTestSetPerceivedClass, "Class override already applied.") + Self.hasRunTestSetPerceivedClass = true + let object = RealClass() XCTAssertTrue(object.objcClass === RealClass.self)