diff --git a/Docs/generation.md b/Docs/generation.md index 6d1a1b1..6449df9 100644 --- a/Docs/generation.md +++ b/Docs/generation.md @@ -143,14 +143,22 @@ Loaded-image reads of relative method/property list-of-lists consult each entry's runtime loaded state, while file-backed reads inspect every structurally valid entry. Both preserve outer-table order and validate the outer table and each nonempty inner member table before decoding; an empty inner list does not -require an otherwise unused entry size. One malformed loaded list preserves its -valid siblings and produces a typed member-list degradation; unloaded lists are -skipped without warning. Once that target is published, PrivateHeaderKit reports -the precise owner and degradation as an `objc-metadata-warning` and persists the -warning in `generation.sqlite`. A bounded diagnostics report records when -additional warnings were omitted, so malformed metadata cannot grow process -output without limit. Live warning presentation is also capped across the run; -one aggregate warning points to the retained per-target details in the database. +require an otherwise unused entry size. Regular method, property, and ivar +tables use the same finite count and byte budgets, checked arithmetic, and +complete-range validation. Loaded class, protocol, and category root tables +validate both the complete pointer table and each referenced layout before +decoding. One malformed relative list, regular member-table entry, loaded-root +entry, or loaded class/category relationship preserves its readable siblings +and produces a typed degradation; unloaded relative lists are skipped without +warning. These guarantees cover structural table, list, and layout ranges; they +do not validate referenced C strings, loaded class RW-extension arrays, or +file-backed root-section tables. Once that target is published, +PrivateHeaderKit reports the precise owner and degradation as an +`objc-metadata-warning` and persists the warning in `generation.sqlite`. A +bounded diagnostics report records when additional warnings were omitted, so +malformed metadata cannot grow process output without limit. Live warning +presentation is also capped across the run; one aggregate warning points to the +retained per-target details in the database. State, attempts, publication intent, and run diagnostics are stored in `generation.sqlite`, outside the published header tree. The `.privateheaderkit` diff --git a/Package.resolved b/Package.resolved index cc80741..a5f3afe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "afe6393989064d60f345d3b63e7668e4541bfdcee8911add15f52d9c8bb39673", + "originHash" : "bf9bc82de8b74cbd9b5723f3edfea31631e1790a92b45368c79b146d9e3fe49b", "pins" : [ { "identity" : "associatedobject", @@ -59,7 +59,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/MachOObjCSection.git", "state" : { - "revision" : "0d17e3d77556991dc128aa92547ea1b1ea8f9e2e" + "revision" : "41a1652d95c34bbb2fdf32ba4b7f5cd9d3ba20ba" } }, { @@ -67,7 +67,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/MachOSwiftSection.git", "state" : { - "revision" : "a7e5982ed7de5dab5dec76036682ea55825b77a8" + "revision" : "3344bd04e6c0819b17df126bea57bae1bbed3022" } }, { diff --git a/Package.swift b/Package.swift index 4785758..7ff494b 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( ), .package( url: "https://github.com/lynnswap/MachOObjCSection.git", - revision: "0d17e3d77556991dc128aa92547ea1b1ea8f9e2e" + revision: "41a1652d95c34bbb2fdf32ba4b7f5cd9d3ba20ba" ), .package( url: "https://github.com/MxIris-Reverse-Engineering/swift-objc-dump.git", @@ -45,7 +45,7 @@ let package = Package( ), .package( url: "https://github.com/lynnswap/MachOSwiftSection.git", - revision: "a7e5982ed7de5dab5dec76036682ea55825b77a8" + revision: "3344bd04e6c0819b17df126bea57bae1bbed3022" ), .package( url: "https://github.com/MxIris-Reverse-Engineering/swift-demangling", diff --git a/Sources/PrivateHeaderKitRawDumpCore/PrivateHeaderKitRawDumpMain.swift b/Sources/PrivateHeaderKitRawDumpCore/PrivateHeaderKitRawDumpMain.swift index cc6b80d..a78de1d 100644 --- a/Sources/PrivateHeaderKitRawDumpCore/PrivateHeaderKitRawDumpMain.swift +++ b/Sources/PrivateHeaderKitRawDumpCore/PrivateHeaderKitRawDumpMain.swift @@ -1156,11 +1156,14 @@ private func dumpObjC( options: DumpOptions, fileManager: FileManager ) async throws { - var metadata = switch machO { + var metadata: CollectedObjCMetadata + switch machO { case .file(let file): - collectObjCMetadata(from: file.objc, in: machO, options: options) + metadata = collectObjCMetadata(from: file.objc, in: machO, options: options) case .loaded(let image): - collectObjCMetadata(from: image.objc, in: machO, options: options) + let roots = image.objc.readRoots() + options.objcDiagnostics.append(contentsOf: roots.tableDiagnostics) + metadata = collectObjCMetadata(from: roots, in: machO, options: options) } #if canImport(ObjectiveC) @@ -1198,6 +1201,24 @@ private struct CollectedObjCMetadata { var runtimeOriginClassNames: Set = [] } +private protocol RawDumpObjCRoots { + var classes64: [ObjCClass64]? { get } + var classes32: [ObjCClass32]? { get } + var nonLazyClasses64: [ObjCClass64]? { get } + var nonLazyClasses32: [ObjCClass32]? { get } + var protocols64: [ObjCProtocol64]? { get } + var protocols32: [ObjCProtocol32]? { get } + var categories64: [ObjCCategory64]? { get } + var categories32: [ObjCCategory32]? { get } + var nonLazyCategories64: [ObjCCategory64]? { get } + var nonLazyCategories32: [ObjCCategory32]? { get } + var categories2_64: [ObjCCategory64]? { get } + var categories2_32: [ObjCCategory32]? { get } +} + +extension MachOFile.ObjectiveC: RawDumpObjCRoots {} +extension ObjCImageRootReadResult: RawDumpObjCRoots {} + @discardableResult func supplementMissingRuntimeClassInfos( _ runtimeInfos: [ObjCClassInfo], @@ -1242,8 +1263,8 @@ private func runtimeClassSortKey(_ name: String) -> String { return "\(rank):\(name)" } -private func collectObjCMetadata( - from objc: Section, +private func collectObjCMetadata( + from objc: Roots, in machO: RawMachO, options: DumpOptions ) -> CollectedObjCMetadata { diff --git a/Sources/PrivateHeaderKitRawDumpCore/RawDumpObjCDiagnostics.swift b/Sources/PrivateHeaderKitRawDumpCore/RawDumpObjCDiagnostics.swift index d04e0ec..2b917b7 100644 --- a/Sources/PrivateHeaderKitRawDumpCore/RawDumpObjCDiagnostics.swift +++ b/Sources/PrivateHeaderKitRawDumpCore/RawDumpObjCDiagnostics.swift @@ -75,6 +75,7 @@ final class RawDumpObjCDiagnosticsAccumulator { append(contentsOf: result.fieldDiagnostics) append(contentsOf: result.diagnostics) append(contentsOf: result.memberListDiagnostics) + append(contentsOf: result.tableDiagnostics) } func append(contentsOf diagnostics: [ObjCMetadataFieldDiagnostic]) { @@ -95,6 +96,12 @@ final class RawDumpObjCDiagnosticsAccumulator { } } + func append(contentsOf diagnostics: [ObjCMetadataTableDiagnostic]) { + for diagnostic in diagnostics { + memberDiagnostics.append(privateHeaderKitDiagnostic(from: diagnostic)) + } + } + var report: PrivateHeaderKitRawDumpDiagnosticsReport { var selected: [PrivateHeaderKitRawDumpDiagnostic] = [] selected.reserveCapacity(PrivateHeaderKitRawDumpDiagnosticsReport.maximumDiagnosticCount) @@ -251,6 +258,45 @@ private func privateHeaderKitDiagnostic( ) } +private func privateHeaderKitDiagnostic( + from diagnostic: ObjCMetadataTableDiagnostic +) -> PrivateHeaderKitRawDumpDiagnostic { + rawDumpMetadataTableDiagnostic( + owner: diagnostic.owner, + site: diagnostic.site, + failure: diagnostic.failure + ) +} + +private func rawDumpMetadataTableDiagnostic( + owner: ObjCMetadataTableDiagnostic.Owner, + site: ObjCMetadataTableDiagnostic.Site, + failure: ObjCMetadataTableDiagnostic.Failure +) -> PrivateHeaderKitRawDumpDiagnostic { + let ownerDescription: String + let metadataDescription: String + switch owner { + case let .member(subject, kind): + ownerDescription = subjectDescription(subject) + metadataDescription = "\(metadataTableMemberKindDescription(kind)) metadata table" + case let .loadedImageRoot(section, pointerWidth): + ownerDescription = "Objective-C loaded-image roots" + metadataDescription = + "\(pointerWidthDescription(pointerWidth))" + + " \(rootSectionDescription(section)) root table" + case let .loadedRelationship(subject, role): + ownerDescription = subjectDescription(subject) + metadataDescription = "\(loadedRelationshipDescription(role)) relationship" + } + + return PrivateHeaderKitRawDumpDiagnostic( + owner: ownerDescription, + degradation: + "\(metadataDescription)\(metadataTableSiteDescription(site))" + + " could not be fully read: \(failureDescription(failure))" + ) +} + func rawDumpMemberListDiagnostic( className: String, kind: ObjCMemberListDiagnostic.Kind, @@ -276,6 +322,81 @@ private func memberKindDescription(_ kind: ObjCMemberListDiagnostic.Kind) -> Str } } +private func metadataTableMemberKindDescription( + _ kind: ObjCMetadataTableDiagnostic.MemberKind +) -> String { + switch kind { + case .ivar: "ivar" + case .instanceMethod: "instance-method" + case .classMethod: "class-method" + case .optionalInstanceMethod: "optional-instance-method" + case .optionalClassMethod: "optional-class-method" + case .instanceProperty: "instance-property" + case .classProperty: "class-property" + } +} + +private func rootSectionDescription( + _ section: ObjCMetadataTableDiagnostic.LoadedImageRootSection +) -> String { + switch section { + case .classList: "class-list" + case .nonLazyClassList: "non-lazy-class-list" + case .protocolList: "protocol-list" + case .categoryList: "category-list" + case .nonLazyCategoryList: "non-lazy-category-list" + case .categoryList2: "category-list-2" + } +} + +private func pointerWidthDescription( + _ pointerWidth: ObjCMetadataTableDiagnostic.PointerWidth +) -> String { + switch pointerWidth { + case .bits32: "32-bit" + case .bits64: "64-bit" + } +} + +private func loadedRelationshipDescription( + _ role: ObjCMetadataTableDiagnostic.LoadedRelationshipRole +) -> String { + switch role { + case .metaclass: "metaclass" + case .superclass: "superclass" + case .categoryClass: "category-class" + case .categoryStubClass: "category-stub-class" + } +} + +private func metadataTableSiteDescription( + _ site: ObjCMetadataTableDiagnostic.Site +) -> String { + switch site { + case .table(let provenance), .relationship(let provenance): + provenanceDescription(provenance) + case let .entry(index, provenance): + " entry \(index)\(provenanceDescription(provenance))" + } +} + +private func provenanceDescription( + _ provenance: ObjCMetadataTableDiagnostic.Provenance +) -> String { + var coordinates: [String] = [] + if let logicalOffset = provenance.logicalOffset { + coordinates.append("logical offset \(logicalOffset)") + } + if let fileOffset = provenance.fileOffset { + coordinates.append("file offset \(fileOffset)") + } + if let imageAddress = provenance.imageAddress { + coordinates.append("image address \(imageAddress)") + } + guard !coordinates.isEmpty else { return "" } + return " (\(coordinates.joined(separator: ", ")))" +} + private func memberLocationDescription( _ location: ObjCMemberListDiagnostic.Location ) -> String { @@ -308,6 +429,20 @@ private func subjectDescription(_ subject: ObjCMetadataFieldDiagnostic.Subject) } } +private func subjectDescription( + _ subject: ObjCMetadataTableDiagnostic.MetadataSubject +) -> String { + switch subject { + case .class(let name): + "Objective-C class \(boundedMetadataString(name))" + case .protocol(let name): + "Objective-C protocol \(boundedMetadataString(name))" + case let .category(className, name): + "Objective-C category \(boundedMetadataString(className))" + + "(\(boundedMetadataString(name)))" + } +} + private func classRoleDescription( _ role: ObjCMetadataFieldDiagnostic.ClassRole ) -> String { @@ -456,3 +591,88 @@ private func failureDescription( "image range at address \(address) is not readable for \(byteCount) bytes" } } + +private func failureDescription( + _ failure: ObjCMetadataTableDiagnostic.Failure +) -> String { + switch failure { + case .unsupportedListEncoding: + "list encoding is unsupported by this reader" + case .invalidListOffset(let offset): + "list offset \(offset) is not a readable address" + case .invalidElementCount(let count): + "element count \(count) cannot be represented" + case .invalidSignedElementCount(let count): + "signed element count \(count) is negative" + case .invalidElementStride(let stride): + "element stride \(stride) cannot be represented" + case let .elementStrideTooSmall(advertised, minimum): + "element stride \(advertised) is smaller than \(minimum)" + case let .unexpectedElementStride(advertised, expected): + "element stride \(advertised) does not match expected size \(expected)" + case let .misalignedTableOffset(offset, requiredAlignment): + "table offset \(offset) is not aligned to \(requiredAlignment) bytes" + case let .misalignedTableAddress(address, requiredAlignment): + "table address \(address) is not aligned to \(requiredAlignment) bytes" + case let .excessiveElementCount(actual, maximum): + "element count \(actual) exceeds the safety limit \(maximum)" + case let .excessiveByteCount(actual, maximum): + "table size \(actual) bytes exceeds the safety limit \(maximum)" + case let .byteCountOverflow(elementCount, elementSize): + "byte count overflowed for \(elementCount) elements of size \(elementSize)" + case let .rangeOverflow(startOffset, byteCount): + "range overflowed from offset \(startOffset) for \(byteCount) bytes" + case let .unreadableFileRange(offset, byteCount): + "file range at offset \(offset) is not readable for \(byteCount) bytes" + case let .unreadableImageRange(address, byteCount): + "image range at address \(address) is not readable for \(byteCount) bytes" + case .invalidFileListOffset(let offset): + "file list offset \(offset) cannot be represented" + case .unresolvedListPointer: + "list pointer could not be rebased" + case .missingListBackingData: + "list pointer has no readable backing data" + case let .unreadableFileHeader(offset, byteCount): + "file header at offset \(offset) is not readable for \(byteCount) bytes" + case .invalidEntryLogicalOffset: + "entry logical offset overflowed" + case .invalidMethodImplementationOffset: + "method implementation offset overflowed" + case .invalidRelativeDisplacement: + "relative field displacement overflowed" + case let .invalidSectionByteCount(byteCount, pointerSize): + "section size \(byteCount) is not a multiple of pointer size \(pointerSize)" + case let .invalidSectionCoordinates( + sectionAddress, + sectionSize, + sectionFileOffset, + segmentAddress, + segmentSize, + segmentFileOffset, + segmentFileSize + ): + "section coordinates (address \(sectionAddress), size \(sectionSize)," + + " file offset \(sectionFileOffset)) are outside segment coordinates" + + " (address \(segmentAddress), size \(segmentSize)," + + " file offset \(segmentFileOffset), file size \(segmentFileSize))" + case .missingImageBaseSegment: + "the loaded image has no __TEXT base segment" + case let .invalidLoadedSectionAddress( + imageBase, + imageVirtualMemoryAddress, + sectionAddress + ): + "section address \(sectionAddress) cannot be mapped from image base" + + " \(imageBase) and image virtual address \(imageVirtualMemoryAddress)" + case .invalidPointer(let rawValue): + "pointer value \(rawValue) is not a readable address" + case .missingReferencedImage(let address): + "address \(address) does not belong to an available loaded image" + case let .unreadableReferencedLayout(address, byteCount): + "referenced layout at image address \(address)" + + " is not readable for \(byteCount) bytes" + case let .invalidEntryArithmetic(baseAddress, targetAddress): + "target address \(targetAddress) cannot be represented relative" + + " to base address \(baseAddress)" + } +} diff --git a/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift b/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift index b22c9de..5013fcc 100644 --- a/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift +++ b/Tests/PrivateHeaderKitHelperProtocolTests/PrivateHeaderKitHelperProtocolTests.swift @@ -292,7 +292,7 @@ struct PrivateHeaderKitHelperProtocolTests { let state = try #require(pin["state"] as? [String: Any]) #expect(pin["location"] as? String == "https://github.com/lynnswap/MachOObjCSection.git") - #expect(state["revision"] as? String == "0d17e3d77556991dc128aa92547ea1b1ea8f9e2e") + #expect(state["revision"] as? String == "41a1652d95c34bbb2fdf32ba4b7f5cd9d3ba20ba") #expect(state["version"] == nil) let swiftSectionPin = try #require( @@ -307,7 +307,7 @@ struct PrivateHeaderKitHelperProtocolTests { ) #expect( swiftSectionState["revision"] as? String - == "a7e5982ed7de5dab5dec76036682ea55825b77a8" + == "3344bd04e6c0819b17df126bea57bae1bbed3022" ) #expect(swiftSectionState["version"] == nil) diff --git a/Tests/PrivateHeaderKitRawDumpTests/ObjCMemberListDiagnosticsTests.swift b/Tests/PrivateHeaderKitRawDumpTests/ObjCMemberListDiagnosticsTests.swift index c4812df..c649bc6 100644 --- a/Tests/PrivateHeaderKitRawDumpTests/ObjCMemberListDiagnosticsTests.swift +++ b/Tests/PrivateHeaderKitRawDumpTests/ObjCMemberListDiagnosticsTests.swift @@ -6,6 +6,35 @@ import Testing @testable import PrivateHeaderKitRawDumpCore struct ObjCMemberListDiagnosticsTests { + @Test func regularTableDiagnosticsUseTheExistingBoundedMemberChannel() throws { + let fixture = try InvalidMemberListFixture( + memberDiagnosticCount: 0, + includesFieldDiagnostic: false, + includesProtocolDiagnostic: false, + includesTableDiagnostic: true + ) + let result = fixture.objcClass.readInfo(in: fixture.machO) + + #expect(result.fieldDiagnostics.isEmpty) + #expect(result.diagnostics.isEmpty) + #expect(result.memberListDiagnostics.isEmpty) + #expect(result.tableDiagnostics.count == 1) + + let accumulator = RawDumpObjCDiagnosticsAccumulator() + accumulator.append(contentsOf: result) + + let report = accumulator.report + #expect(report.diagnostics.count == 1) + #expect(report.omittedDiagnosticCount == 0) + #expect(report.diagnostics.first?.owner == "Objective-C class MemberOwner") + #expect( + report.diagnostics.first?.degradation + == "ivar metadata table (logical offset 5120, file offset 5120)" + + " could not be fully read: element count 65537" + + " exceeds the safety limit 65536" + ) + } + @Test func wholeTableFailureKeepsMemberKindAndOuterOffset() { let record = rawDumpMemberListDiagnostic( className: "Owner", @@ -202,7 +231,8 @@ private final class InvalidMemberListFixture { init( memberDiagnosticCount: Int, includesFieldDiagnostic: Bool = true, - includesProtocolDiagnostic: Bool = true + includesProtocolDiagnostic: Bool = true, + includesTableDiagnostic: Bool = false ) throws { let fileSize = 0x4000 let vmAddress: UInt64 = 0x1_0000_0000 @@ -266,7 +296,7 @@ private final class InvalidMemberListFixture { data.storeValue( RawEntrySizeListHeader( entsizeAndFlags: UInt32(MemoryLayout.size), - count: 1 + count: includesTableDiagnostic ? 65_537 : 1 ), at: ivarListOffset ) @@ -291,7 +321,9 @@ private final class InvalidMemberListFixture { name: address(classNameOffset), baseMethods: address(memberListOffset) | 1, baseProtocols: includesProtocolDiagnostic ? address(protocolListOffset) : 0, - ivars: includesFieldDiagnostic ? address(ivarListOffset) : 0, + ivars: includesFieldDiagnostic || includesTableDiagnostic + ? address(ivarListOffset) + : 0, weakIvarLayout: 0, baseProperties: 0 ),