Files
ipsw/internal/diff/ipsw_artifacts.go
blacktop e209828c31 feat(diff): add cacheable task-based reports
Refactor diff jobs into task-owned renderers with persistent cache support, root-confined walkers, and quieter Mach-O/Firmware output.
2026-06-04 12:02:46 -06:00

586 lines
18 KiB
Go

package diff
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/blacktop/go-macho"
"github.com/blacktop/ipsw/pkg/info"
"github.com/blacktop/ipsw/pkg/plist"
)
func (d *Diff) indexIdenticalIPSWArtifacts() {
d.sameKernel = false
d.sameVolumes = make(map[string]bool)
if d.Old.InputMode != inputModeIPSW || d.New.InputMode != inputModeIPSW {
return
}
d.sameKernel = ipswKernelcacheManifestDigestsEqual(d.Old.Info, d.New.Info)
for _, typ := range ipswVolumeOrderMachos {
if ipswVolumeManifestDigestsEqual(d.Old.Info, d.New.Info, typ) {
d.sameVolumes[typ] = true
}
}
}
func (d *Diff) ipswVolumeUnchanged(typ string) bool {
return d.sameVolumes != nil && d.sameVolumes[typ]
}
func (d *Diff) dscVolumeUnchanged() bool {
if d.Old.InputMode != inputModeIPSW || d.New.InputMode != inputModeIPSW || !hasBuildManifest(d.Old.Info) || !hasBuildManifest(d.New.Info) {
return false
}
if d.ipswVolumeUnchanged("sys") {
return true
}
if volumeResolves(d.Old.Info, "sys") || volumeResolves(d.New.Info, "sys") {
return false
}
return d.ipswVolumeUnchanged("fs")
}
func (d *Diff) allIPSWOSVolumesUnchanged() bool {
if d.Old.InputMode != inputModeIPSW || d.New.InputMode != inputModeIPSW || !hasBuildManifest(d.Old.Info) || !hasBuildManifest(d.New.Info) {
return false
}
var sawVolume bool
for _, typ := range ipswVolumeOrderMachos {
oldPresent := volumeResolves(d.Old.Info, typ)
newPresent := volumeResolves(d.New.Info, typ)
if oldPresent != newPresent {
return false
}
if !oldPresent {
continue
}
sawVolume = true
if !d.ipswVolumeUnchanged(typ) {
return false
}
}
return sawVolume
}
func ipswKernelcacheManifestDigestsEqual(oldInfo, newInfo *info.Info) bool {
if !hasBuildManifest(oldInfo) || !hasBuildManifest(newInfo) {
return false
}
oldKCs := oldInfo.Plists.BuildManifest.GetKernelCaches()
newKCs := newInfo.Plists.BuildManifest.GetKernelCaches()
if len(oldKCs) == 0 || len(oldKCs) != len(newKCs) {
return false
}
models := make([]string, 0, len(oldKCs))
for model := range oldKCs {
models = append(models, model)
}
slices.Sort(models)
for _, model := range models {
oldPaths := oldKCs[model]
newPaths, ok := newKCs[model]
if !ok || len(oldPaths) == 0 || len(newPaths) == 0 {
return false
}
oldDigest, ok := uniqueManifestDigestForPath(oldInfo, "KernelCache", oldPaths[0])
if !ok {
return false
}
newDigest, ok := uniqueManifestDigestForPath(newInfo, "KernelCache", newPaths[0])
if !ok {
return false
}
if !bytes.Equal(oldDigest, newDigest) {
return false
}
}
return true
}
func ipswVolumeManifestDigestsEqual(oldInfo, newInfo *info.Info, typ string) bool {
oldDigest, ok := ipswVolumeManifestDigest(oldInfo, typ)
if !ok {
return false
}
newDigest, ok := ipswVolumeManifestDigest(newInfo, typ)
if !ok {
return false
}
return bytes.Equal(oldDigest, newDigest)
}
// ipswVolumeManifestDigest resolves the BuildManifest digest for the DMG that
// backs volume typ (fs/sys/app/exc). It is the single per-volume digest source
// shared by the unchanged-volume short-circuit and machosJob/entsJob InputHash
// fingerprints, so a task's cache identity tracks the exact same artifact bytes
// the orchestrator uses to decide a volume is unchanged. ok is false when no
// BuildManifest, no manifest key, no resolvable path, or no unique digest
// exists for the volume.
func ipswVolumeManifestDigest(inf *info.Info, typ string) ([]byte, bool) {
if !hasBuildManifest(inf) {
return nil, false
}
key, ok := ipswVolumeManifestKey(typ)
if !ok {
return nil, false
}
path, err := ipswVolumePath(inf, typ)
if err != nil {
return nil, false
}
return uniqueManifestDigestForPath(inf, key, path)
}
// kernelcacheDMGInputHash digests the task-scope inputs for the kernelcache
// diff: the old and new BuildManifest KernelCache digests for every model,
// folded over sorted models, old then new. It reuses the exact digest source
// (GetKernelCaches + uniqueManifestDigestForPath under the "KernelCache" key)
// that ipswKernelcacheManifestDigestsEqual uses to decide the kernelcache is
// unchanged, so a task's cache identity tracks the same artifact bytes that
// drive the sameKernel short-circuit. A model with no resolvable digest on a
// side contributes a stable absent marker so its later appearance moves the
// hash.
func kernelcacheDMGInputHash(oldInfo, newInfo *info.Info) string {
h := sha256.New()
writeKernelcacheDigests(h, "old", oldInfo)
writeKernelcacheDigests(h, "new", newInfo)
return hex.EncodeToString(h.Sum(nil))
}
func writeKernelcacheDigests(h io.Writer, side string, inf *info.Info) {
_, _ = h.Write([]byte(side))
_, _ = h.Write([]byte{0})
if !hasBuildManifest(inf) {
_, _ = h.Write([]byte{0x00}) // absent marker
return
}
kcs := inf.Plists.BuildManifest.GetKernelCaches()
models := make([]string, 0, len(kcs))
for model := range kcs {
models = append(models, model)
}
slices.Sort(models)
for _, model := range models {
_, _ = h.Write([]byte(model))
_, _ = h.Write([]byte{0})
paths := kcs[model]
if len(paths) == 0 {
_, _ = h.Write([]byte{0x00}) // absent marker
continue
}
digest, ok := uniqueManifestDigestForPath(inf, "KernelCache", paths[0])
if !ok {
_, _ = h.Write([]byte{0x00}) // absent marker
continue
}
_, _ = h.Write([]byte{0x01}) // present marker
_, _ = h.Write(digest)
_, _ = h.Write([]byte{0})
}
_, _ = h.Write([]byte{0xff})
}
// ibootDMGInputHash digests the task-scope inputs for the iBoot diff: every
// "iBoot" BuildManifest entry digest, deduplicated and sorted, old then new.
// parseIBoot reads the iBoot im4p straight from the IPSW zip (the first member
// matching iBoot\..*\.im4p), so there is no single manifest path to key on;
// folding every distinct iBoot digest tracks any change to the iBoot firmware
// artifact regardless of which per-device variant the zip yields. A side with
// no resolvable iBoot digest contributes a stable absent marker.
func ibootDMGInputHash(oldInfo, newInfo *info.Info) string {
h := sha256.New()
writeIBootDigests(h, "old", oldInfo)
writeIBootDigests(h, "new", newInfo)
return hex.EncodeToString(h.Sum(nil))
}
func writeIBootDigests(h io.Writer, side string, inf *info.Info) {
_, _ = h.Write([]byte(side))
_, _ = h.Write([]byte{0})
digests := ipswIBootManifestDigests(inf)
if len(digests) == 0 {
_, _ = h.Write([]byte{0x00}) // absent marker
return
}
_, _ = h.Write([]byte{0x01}) // present marker
for _, digest := range digests {
_, _ = h.Write(digest)
_, _ = h.Write([]byte{0})
}
_, _ = h.Write([]byte{0xff})
}
// ipswIBootManifestDigests returns every distinct "iBoot" BuildManifest entry
// digest, sorted for determinism. Multiple build identities (per device
// variant) may each carry an iBoot entry; duplicates are collapsed so the
// fingerprint is stable across identity ordering.
func ipswIBootManifestDigests(inf *info.Info) [][]byte {
if !hasBuildManifest(inf) {
return nil
}
seen := make(map[string]bool)
var digests [][]byte
for _, ident := range inf.Plists.BuildManifest.BuildIdentities {
manifest, ok := ident.Manifest["iBoot"]
if !ok || len(manifest.Digest) == 0 {
continue
}
key := string(manifest.Digest)
if seen[key] {
continue
}
seen[key] = true
digests = append(digests, append([]byte(nil), manifest.Digest...))
}
slices.SortFunc(digests, bytes.Compare)
return digests
}
func ipswVolumeManifestKey(typ string) (string, bool) {
switch typ {
case "fs":
return "OS", true
case "sys":
return "Cryptex1,SystemOS", true
case "app":
return "Cryptex1,AppOS", true
case "exc":
return "Ap,ExclaveOS", true
default:
return "", false
}
}
func ipswVolumePath(inf *info.Info, typ string) (string, error) {
if inf == nil {
return "", info.ErrorCryptexNotFound
}
switch typ {
case "fs":
return inf.GetFileSystemOsDmg()
case "sys":
return inf.GetSystemOsDmg()
case "app":
return inf.GetAppOsDmg()
case "exc":
return inf.GetExclaveOSDmg()
default:
return "", info.ErrorCryptexNotFound
}
}
func uniqueManifestDigestForPath(inf *info.Info, key, path string) ([]byte, bool) {
if !hasBuildManifest(inf) {
return nil, false
}
var digest []byte
for _, ident := range inf.Plists.BuildManifest.BuildIdentities {
manifest, ok := ident.Manifest[key]
if !ok {
continue
}
manifestPath, ok := identityManifestPath(manifest)
if !ok || !strings.EqualFold(manifestPath, path) {
continue
}
if len(manifest.Digest) == 0 {
return nil, false
}
if digest == nil {
digest = append([]byte(nil), manifest.Digest...)
continue
}
if !bytes.Equal(digest, manifest.Digest) {
return nil, false
}
}
return digest, digest != nil
}
func identityManifestPath(manifest plist.IdentityManifest) (string, bool) {
if manifest.Info == nil {
return "", false
}
path, ok := manifest.Info["Path"].(string)
if !ok || path == "" {
return "", false
}
return path, true
}
func hasBuildManifest(inf *info.Info) bool {
return inf != nil && inf.Plists != nil && inf.Plists.BuildManifest != nil
}
// volumeDMGInputHash digests the task-scope inputs shared by every job that
// reads all four IPSW OS volumes (fs/sys/app/exc). It is the InputHash source
// for machosJob, entsJob, featuresJob, and locsJob — all walk the identical
// four volumes, so they share one fingerprint. It delegates to
// volumeDMGInputHashFor with the full volume set.
func volumeDMGInputHash(oldInfo, newInfo *info.Info) string {
return volumeDMGInputHashFor(oldInfo, newInfo, ipswVolumeOrderMachos...)
}
// volumeDMGInputHashFor digests the old and new BuildManifest DMG digests for
// the named volumes, in the order given. It is the per-volume fingerprint
// backing every OS-volume job's InputHash; callers that read all four volumes
// pass fs/sys/app/exc (via volumeDMGInputHash), while a single-volume job like
// launchdJob passes just "fs" so its cache identity ignores sys/app/exc.
//
// It reuses ipswVolumeManifestDigest, the same per-volume digest source the
// unchanged-volume short-circuit uses, so a task's cache identity tracks the
// exact artifact bytes that decide whether a volume changed. A volume with no
// resolvable digest contributes a stable absent marker so its later appearance
// changes the hash.
func volumeDMGInputHashFor(oldInfo, newInfo *info.Info, typs ...string) string {
h := sha256.New()
for _, typ := range typs {
_, _ = h.Write([]byte(typ))
_, _ = h.Write([]byte{0})
writeVolumeDigest(h, "old", oldInfo, typ)
writeVolumeDigest(h, "new", newInfo, typ)
}
return hex.EncodeToString(h.Sum(nil))
}
func writeVolumeDigest(h io.Writer, side string, inf *info.Info, typ string) {
_, _ = h.Write([]byte(side))
_, _ = h.Write([]byte{0})
digest, ok := ipswVolumeManifestDigest(inf, typ)
if !ok {
_, _ = h.Write([]byte{0x00}) // absent marker
return
}
_, _ = h.Write([]byte{0x01}) // present marker
_, _ = h.Write(digest)
_, _ = h.Write([]byte{0})
}
// zipMember is one entry in an IPSW zip central directory, captured without
// decompressing any data. CRC32 and uncompressed size move when a member's
// content changes; the name moves when a member is added, removed, or renamed.
type zipMember struct {
name string
crc uint32
size uint64
}
// readZipCentralDirectory enumerates an IPSW zip's central directory and
// returns one zipMember per non-directory entry (name, CRC32, uncompressed
// size). No member data is decompressed. It is a package var so tests can
// substitute a fake listing without writing a real zip to disk: filesJob's
// InputHash must change when this listing changes even though no DMG digest
// moves.
var readZipCentralDirectory = func(ipswPath string) ([]zipMember, error) {
zr, err := zip.OpenReader(ipswPath)
if err != nil {
return nil, fmt.Errorf("failed to open IPSW zip %s: %w", ipswPath, err)
}
defer zr.Close()
members := make([]zipMember, 0, len(zr.File))
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
members = append(members, zipMember{
name: f.Name,
crc: f.CRC32,
size: f.UncompressedSize64,
})
}
return members, nil
}
// ipswZipListingDigest fingerprints the IPSW zip itself — every member's name,
// CRC32, and uncompressed size, sorted for determinism. It detects loose zip
// members added, removed, or changed at the zip root, which move no DMG digest
// and so are invisible to volumeDMGInputHashFor. filesJob folds this alongside
// the four DMG digests because it scans the zip (the "IPSW" pseudo-bucket) in
// addition to the mounted volumes.
func ipswZipListingDigest(ipswPath string) ([]byte, error) {
return zipListingDigest(ipswPath, nil)
}
// ipswFirmwareZipListingDigest fingerprints the firmware artifacts firmwaresJob
// reads from the IPSW zip: every ".im4p" member's name, CRC32, and uncompressed
// size, sorted for determinism. DiffFirmwares → search.ForEachIm4pInIPSW selects
// exactly the members whose extension is ".im4p" (armfw ftab bundles, exclave
// bundles, and plain firmware payloads all match), so these loose zip members —
// not any BuildManifest DMG entry — are the firmware diff's true inputs. Folding
// them detects any firmware im4p added, removed, or changed even though no DMG
// digest moves. It reuses the readZipCentralDirectory seam so tests can substitute
// a fake listing.
func ipswFirmwareZipListingDigest(ipswPath string) ([]byte, error) {
return zipListingDigest(ipswPath, func(m zipMember) bool {
return filepath.Ext(m.name) == ".im4p"
})
}
// zipListingDigest reads the zip central directory, keeps the members the
// filter accepts (nil keeps everything), and folds each kept member's
// name+CRC32+size into a sha256 in sorted-name order. Shared by the files
// (all members) and firmwares (.im4p members) InputHash computations so the
// fold format cannot drift between them.
func zipListingDigest(ipswPath string, filter func(zipMember) bool) ([]byte, error) {
members, err := readZipCentralDirectory(ipswPath)
if err != nil {
return nil, err
}
kept := members
if filter != nil {
kept = make([]zipMember, 0, len(members))
for _, m := range members {
if filter(m) {
kept = append(kept, m)
}
}
}
slices.SortFunc(kept, func(a, b zipMember) int {
return strings.Compare(a.name, b.name)
})
h := sha256.New()
for _, m := range kept {
_, _ = h.Write([]byte(m.name))
_, _ = h.Write([]byte{0})
var b [12]byte
binary.BigEndian.PutUint32(b[:4], m.crc)
binary.BigEndian.PutUint64(b[4:], m.size)
_, _ = h.Write(b[:])
}
return h.Sum(nil), nil
}
func filesSHA256Equal(oldPath, newPath string) (bool, error) {
oldInfo, err := os.Stat(oldPath)
if err != nil {
return false, fmt.Errorf("failed to stat old file %s: %w", oldPath, err)
}
newInfo, err := os.Stat(newPath)
if err != nil {
return false, fmt.Errorf("failed to stat new file %s: %w", newPath, err)
}
if !oldInfo.Mode().IsRegular() || !newInfo.Mode().IsRegular() {
return false, nil
}
if oldInfo.Size() != newInfo.Size() {
return false, nil
}
oldHash, err := fileSHA256(oldPath)
if err != nil {
return false, err
}
newHash, err := fileSHA256(newPath)
if err != nil {
return false, err
}
return oldHash == newHash, nil
}
func fileSHA256(path string) ([sha256.Size]byte, error) {
var sum [sha256.Size]byte
f, err := os.Open(filepath.Clean(path))
if err != nil {
return sum, fmt.Errorf("failed to open %s: %w", path, err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return sum, fmt.Errorf("failed to hash %s: %w", path, err)
}
copy(sum[:], h.Sum(nil))
return sum, nil
}
// kernelcacheNoiseSegments are kernelcache segments whose bytes differ even
// when the kernel is functionally unchanged across a rebuild. Excluding them
// from the equality check lets us detect "Apple rebuilt the kernel without
// changing it" while still catching real code/data/symbol changes.
//
// - __TEXT carries the mach_header and load commands, including LC_UUID
// (regenerated per build).
// - __PRELINK_INFO carries the kext bundle plist, which embeds build-root
// paths, per-build hashes, and the build label (e.g. "23F77"). Plist-only
// changes are also invisible to the existing kernel diff path, so skipping
// this segment doesn't widen the blind spot.
var kernelcacheNoiseSegments = map[string]bool{
"__TEXT": true,
"__PRELINK_INFO": true,
}
// kernelKeySegmentsEqual reports whether the two kernelcache Mach-Os carry the
// same bytes in every segment that holds functional content — code, constants,
// initialized data, and symbol tables — ignoring kernelcacheNoiseSegments.
// When true, no kernel code, kext code, constant data, mutable data, or symbol
// information differs even if the wrapper bytes (UUID, build-root strings,
// plist digests) do.
//
// Returns false if either side has a segment the other doesn't (after ignoring
// noise) or any checked segment is unreadable on either side; callers should
// treat that as "can't prove equal, run the full diff".
func kernelKeySegmentsEqual(oldKC, newKC *macho.File) bool {
oldSegs := functionalSegments(oldKC)
newSegs := functionalSegments(newKC)
if len(oldSegs) == 0 || len(oldSegs) != len(newSegs) {
return false
}
for name, oldSeg := range oldSegs {
newSeg, ok := newSegs[name]
if !ok {
return false
}
if !segmentBytesEqual(oldSeg, newSeg) {
return false
}
}
return true
}
func functionalSegments(m *macho.File) map[string]*macho.Segment {
out := make(map[string]*macho.Segment)
for _, s := range m.Segments() {
if kernelcacheNoiseSegments[s.Name] {
continue
}
out[s.Name] = s
}
return out
}
func segmentBytesEqual(a, b *macho.Segment) bool {
if a == nil || b == nil {
return false
}
if a.Filesz != b.Filesz || a.Filesz == 0 {
return false
}
da, err := a.Data()
if err != nil || uint64(len(da)) != a.Filesz {
return false
}
db, err := b.Data()
if err != nil || uint64(len(db)) != b.Filesz {
return false
}
return bytes.Equal(da, db)
}