// Package diff provides a way to diff two ipsws package diff import ( "archive/zip" "encoding/gob" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "sort" "strings" "sync" "time" "unicode" "github.com/apex/log" "github.com/blacktop/go-macho" dcmd "github.com/blacktop/ipsw/internal/commands/dsc" "github.com/blacktop/ipsw/internal/commands/dwarf" "github.com/blacktop/ipsw/internal/commands/ent" "github.com/blacktop/ipsw/internal/commands/extract" kcmd "github.com/blacktop/ipsw/internal/commands/kernel" mcmd "github.com/blacktop/ipsw/internal/commands/macho" "github.com/blacktop/ipsw/internal/search" "github.com/blacktop/ipsw/internal/utils" "github.com/blacktop/ipsw/pkg/aea" "github.com/blacktop/ipsw/pkg/dyld" "github.com/blacktop/ipsw/pkg/iboot" "github.com/blacktop/ipsw/pkg/img4" "github.com/blacktop/ipsw/pkg/info" "github.com/blacktop/ipsw/pkg/kernelcache" otapkg "github.com/blacktop/ipsw/pkg/ota" "github.com/blacktop/ipsw/pkg/signature" "golang.org/x/exp/maps" ) type kernel struct { Path string Version *kernelcache.Version Kexts []string } type mount struct { DmgPath string MountPath string IsMounted bool CleanupPaths []string } type PlistDiff struct { New map[string]string `json:"new,omitempty"` Removed []string `json:"removed,omitempty"` Updated map[string]string `json:"changed,omitempty"` } type FileDiff struct { New map[string][]string `json:"new,omitempty"` Removed map[string][]string `json:"removed,omitempty"` // Updated map[string]string `json:"changed,omitempty"` } type IBootDiff struct { Versions []string `json:"versions,omitempty"` New map[string][]string `json:"new,omitempty"` Removed map[string][]string `json:"removed,omitempty"` } type Config struct { Title string IpswOld string IpswNew string KDKs []string LaunchD bool Firmware bool Features bool Files bool Sandbox bool CStrings bool FuncStarts bool Entitlements bool AllowList []string BlockList []string PemDB string Signatures string Output string Verbose bool LowMemory bool AEAKeyDB string AEAKeyVal string AEAInsecure bool } // Context is the context for the diff type Context struct { IPSWPath string InputMode inputMode Info *info.Info Version string Build string Folder string Mount map[string]mount SystemOsDmgPath string MountPath string IsMounted bool IsMacOS bool Kernel kernel DSC string Webkit string KDK string PemDB string // otaFile is unexported to avoid gob serialization issues // in Diff.Save(). Nil unless InputMode == inputModeOTA. otaFile *otapkg.AA // payloadRoot is the extracted OTA payload filesystem root. // Unexported so gob ignores it in Diff.Save(). payloadRoot string payloadReady bool mu *sync.Mutex } // Diff is the diff type Diff struct { Title string `json:"title,omitempty"` Old Context `json:"-"` New Context `json:"-"` Kexts *mcmd.MachoDiff `json:"kexts,omitempty"` KDKs string `json:"kdks,omitempty"` Ents string `json:"ents,omitempty"` Dylibs *mcmd.MachoDiff `json:"dylibs,omitempty"` Machos *mcmd.MachoDiff `json:"machos,omitempty"` Firmwares *mcmd.MachoDiff `json:"firmwares,omitempty"` IBoot *IBootDiff `json:"iboot,omitempty"` Launchd string `json:"launchd,omitempty"` Sandbox string `json:"sandbox,omitempty"` Features *PlistDiff `json:"features,omitempty"` Files *FileDiff `json:"files,omitempty"` tmpDir string `json:"-"` conf *Config } // New news the diff func New(conf *Config) *Diff { if len(conf.KDKs) == 0 { return &Diff{ Title: conf.Title, Old: Context{ IPSWPath: conf.IpswOld, Mount: make(map[string]mount), }, New: Context{ IPSWPath: conf.IpswNew, Mount: make(map[string]mount), }, conf: conf, } } return &Diff{ Title: conf.Title, Old: Context{ IPSWPath: conf.IpswOld, Mount: make(map[string]mount), KDK: conf.KDKs[0], }, New: Context{ IPSWPath: conf.IpswNew, Mount: make(map[string]mount), KDK: conf.KDKs[1], }, conf: conf, } } func (d *Diff) SetOutput(output string) { if d.conf == nil { d.conf = &Config{Output: output} } else { d.conf.Output = output } } func (d *Diff) TitleToFilename() string { var out strings.Builder lastUnderscore := false writeUnderscore := func() { if lastUnderscore { return } out.WriteByte('_') lastUnderscore = true } for _, r := range d.Title { switch { case unicode.IsLetter(r), unicode.IsDigit(r), r == '_', r == '-', r == ',': out.WriteRune(r) lastUnderscore = false case unicode.IsSpace(r), r == '.', r == '(', r == ')', r == '/', r == '\\', r == ':': writeUnderscore() default: writeUnderscore() } } filename := strings.Trim(out.String(), "_") if filename == "" { return "diff" } return filename } // Save saves the diff func (d *Diff) Save() error { if err := os.MkdirAll(d.conf.Output, 0755); err != nil { return err } idiff, err := os.Create(filepath.Join(d.conf.Output, d.TitleToFilename()+".idiff")) if err != nil { return err } defer idiff.Close() d.Old.Info = nil d.New.Info = nil gob.Register([]any{}) gob.Register(map[string]any{}) log.Infof("Saving pickled IPSW diff: %s", idiff.Name()) if err := gob.NewEncoder(idiff).Encode(&d); err != nil { return fmt.Errorf("failed to encode diff: %v", err) } return nil } func (d *Diff) getInfo() (err error) { mode, err := detectInputMode(d.Old.IPSWPath, d.New.IPSWPath) if err != nil { return err } d.Old.PemDB = d.conf.PemDB d.New.PemDB = d.conf.PemDB if mode == inputModeDirectory { d.Old.InputMode = inputModeDirectory d.New.InputMode = inputModeDirectory configureDirectoryContext(&d.Old) configureDirectoryContext(&d.New) if d.Title == "" { d.Title = fmt.Sprintf("%s .vs %s", d.Old.Build, d.New.Build) } return nil } // Probe both sides for OTA before committing to IPSW mode. oldOTA, oldInfo, oldErr := tryOpenOTA(d.Old.IPSWPath, d.conf) newOTA, newInfo, newErr := tryOpenOTA(d.New.IPSWPath, d.conf) // Handle fatal errors — clean up any opened handle. if oldErr != nil { if newOTA != nil { newOTA.Close() } return fmt.Errorf("failed to probe 'Old' input: %w", oldErr) } if newErr != nil { if oldOTA != nil { oldOTA.Close() } return fmt.Errorf("failed to probe 'New' input: %w", newErr) } // Classify symmetrically. switch { case oldOTA != nil && newOTA != nil: // Both are OTAs — validate scope (Phase 1: full OTAs only). if err := validateOTAScope(oldInfo); err != nil { oldOTA.Close() newOTA.Close() return fmt.Errorf("'Old' OTA: %w", err) } if err := validateOTAScope(newInfo); err != nil { oldOTA.Close() newOTA.Close() return fmt.Errorf("'New' OTA: %w", err) } d.Old.otaFile = oldOTA d.New.otaFile = newOTA configureOTAContext(&d.Old, oldInfo, d.tmpDir) configureOTAContext(&d.New, newInfo, d.tmpDir) if isMacOSOTA(oldInfo) || isMacOSOTA(newInfo) { d.Old.IsMacOS = true d.New.IsMacOS = true } if d.Title == "" { d.Title = fmt.Sprintf( "%s (%s) .vs %s (%s)", d.Old.Version, d.Old.Build, d.New.Version, d.New.Build, ) } return nil case oldOTA == nil && newOTA == nil: // Neither is OTA — fall through to IPSW mode. default: // Mixed: one OTA, one not. if oldOTA != nil { oldOTA.Close() } if newOTA != nil { newOTA.Close() } return fmt.Errorf( "inputs must both be IPSW files, OTA files, " + "or directories of patched OTA DMGs", ) } // IPSW mode. d.Old.InputMode = inputModeIPSW d.New.InputMode = inputModeIPSW d.Old.Info, err = info.Parse(d.Old.IPSWPath) if err != nil { return fmt.Errorf("failed to parse 'Old' IPSW: %v", err) } d.New.Info, err = info.Parse(d.New.IPSWPath) if err != nil { return fmt.Errorf("failed to parse 'New' IPSW: %v", err) } d.Old.Version = d.Old.Info.Plists.BuildManifest.ProductVersion d.Old.Build = d.Old.Info.Plists.BuildManifest.ProductBuildVersion d.Old.Folder, err = d.Old.Info.GetFolder() if err != nil { log.Errorf("failed to get folder from 'Old' IPSW metadata: %v", err) } d.Old.Folder = filepath.Join(d.tmpDir, d.Old.Folder) d.New.Version = d.New.Info.Plists.BuildManifest.ProductVersion d.New.Build = d.New.Info.Plists.BuildManifest.ProductBuildVersion d.New.Folder, err = d.New.Info.GetFolder() if err != nil { log.Errorf("failed to get folder from 'New' IPSW metadata: %v", err) } d.New.Folder = filepath.Join(d.tmpDir, d.New.Folder) if d.Title == "" { d.Title = fmt.Sprintf("%s (%s) .vs %s (%s)", d.Old.Version, d.Old.Build, d.New.Version, d.New.Build) } if d.Old.Info.IsMacOS() || d.New.Info.IsMacOS() { d.Old.IsMacOS = true d.New.IsMacOS = true } return nil } // Diff diffs the diff func (d *Diff) Diff() (err error) { d.tmpDir, err = os.MkdirTemp(os.TempDir(), "ipsw-diff") if err != nil { return fmt.Errorf("failed to create temp dir: %v", err) } defer os.RemoveAll(d.tmpDir) if err := d.getInfo(); err != nil { return err } // Close OTA handles when done (after all extractions). if d.Old.otaFile != nil { defer d.Old.otaFile.Close() } if d.New.otaFile != nil { defer d.New.otaFile.Close() } directoryMode := d.Old.InputMode == inputModeDirectory otaMode := d.Old.InputMode == inputModeOTA if directoryMode { if unsupported := unsupportedFlagsForDirectoryMode(d.conf); len(unsupported) > 0 { log.Warnf("Directory inputs do not support %s; skipping those sections", strings.Join(unsupported, ", ")) } log.Info("Mounting patched OTA DMGs") if err := d.mountSystemOsDMGs(); err != nil { return fmt.Errorf("failed to mount DMGs: %v", err) } defer d.unmountSystemOsDMGs() } if otaMode { if unsupported := unsupportedFlagsForOTAMode(d.conf); len(unsupported) > 0 { log.Warnf("OTA mode does not support %s; skipping those sections", strings.Join(unsupported, ", ")) } } if directoryMode { log.Debug("Skipping KERNELCACHES for directory inputs") } else { log.Info("Diffing KERNELCACHES") if err := d.parseKernelcache(); err != nil { log.WithError(err).Error("failed to parse kernelcaches") } } if d.Old.KDK != "" && d.New.KDK != "" { log.Info("Diffing KDKS") if err := d.parseKDKs(); err != nil { log.WithError(err).Error("failed to parse KDKs") } } if d.conf.Sandbox && !directoryMode { log.Info("Diffing Sandbox Profiles") d.Sandbox, err = d.parseSandboxProfiles() if err != nil { log.WithError(err).Error("failed to diff sandbox profiles") } } log.Info("Diffing DYLD_SHARED_CACHES") if !directoryMode { if err := d.mountSystemOsDMGs(); err != nil { return fmt.Errorf("failed to mount DMGs: %v", err) } defer d.unmountSystemOsDMGs() } if err := d.parseDSC(); err != nil { log.WithError(err).Error("failed to parse DSCs") } log.Info("Diffing MachOs") if err := d.parseMachos(); err != nil { log.WithError(err).Error("failed to parse MachOs") } if d.conf.LaunchD && !directoryMode { log.Info("Diffing launchd PLIST") if err := d.parseLaunchdPlists(); err != nil { log.WithError(err).Error("failed to parse launchd plists") } } if d.conf.Firmware { log.Info("Diffing Firmware") if err := d.parseFirmwares(); err != nil { log.WithError(err).Error("failed to parse firmwares") } if !directoryMode { log.Info("Diffing iBoot") if err := d.parseIBoot(); err != nil { log.WithError(err).Error("failed to parse iBoot") } } } if d.conf.Features { log.Info("Diffing Feature Flags") if err := d.parseFeatureFlags(); err != nil { log.WithError(err).Error("failed to parse feature flags") } } if d.conf.Files { log.Info("Diffing Files") if err := d.parseFiles(); err != nil { log.WithError(err).Error("failed to parse files") } } if d.conf.Entitlements { log.Info("Diffing ENTITLEMENTS") d.Ents, err = d.parseEntitlements() if err != nil { log.WithError(err).Error("failed to parse entitlements") } } return nil } func mountDMG(ctx *Context) (err error) { ctx.SystemOsDmgPath, err = ctx.Info.GetSystemOsDmg() if err != nil { if errors.Is(err, info.ErrorCryptexNotFound) { utils.Indent(log.Warn, 2)("failed to get SystemOS DMG; trying filesystem DMG") ctx.SystemOsDmgPath, err = ctx.Info.GetFileSystemOsDmg() if err != nil { return fmt.Errorf("failed to get filesystem DMG: %v", err) } } else { return fmt.Errorf("failed to get SystemOS DMG: %v", err) } } if _, err := os.Stat(ctx.SystemOsDmgPath); os.IsNotExist(err) { dmgs, err := utils.Unzip(ctx.IPSWPath, "", func(f *zip.File) bool { return strings.EqualFold(filepath.Base(f.Name), ctx.SystemOsDmgPath) }) if err != nil { return fmt.Errorf("failed to extract %s from IPSW: %v", ctx.SystemOsDmgPath, err) } if len(dmgs) == 0 { return fmt.Errorf("failed to find %s in IPSW", ctx.SystemOsDmgPath) } } else { utils.Indent(log.Debug, 2)(fmt.Sprintf("Found extracted %s", ctx.SystemOsDmgPath)) } if filepath.Ext(ctx.SystemOsDmgPath) == ".aea" { aeaPath := ctx.SystemOsDmgPath ctx.SystemOsDmgPath, err = aea.Decrypt(&aea.DecryptConfig{ Input: aeaPath, Output: filepath.Dir(aeaPath), PemDB: ctx.PemDB, Proxy: "", // TODO: make proxy configurable Insecure: false, // TODO: make insecure configurable }) if err != nil { return fmt.Errorf("failed to parse AEA encrypted DMG: %v", err) } // Verify the decrypted file exists before proceeding if _, err := os.Stat(ctx.SystemOsDmgPath); err != nil { return fmt.Errorf("decrypted DMG file not found: %v", err) } // Remove the original .aea file to avoid leaving it around if err := os.Remove(aeaPath); err != nil { utils.Indent(log.Warn, 3)(fmt.Sprintf("failed to remove original .aea file: %v", err)) } } utils.Indent(log.Info, 2)(fmt.Sprintf("Mounting %s", ctx.SystemOsDmgPath)) ctx.MountPath, ctx.IsMounted, err = utils.MountDMG(ctx.SystemOsDmgPath, "") if err != nil { if !errors.Is(err, utils.ErrMountResourceBusy) { return fmt.Errorf("failed to mount DMG: %v", err) } } if ctx.IsMounted { utils.Indent(log.Info, 3)(fmt.Sprintf("%s already mounted", ctx.SystemOsDmgPath)) } return nil } func (d *Diff) mountSystemOsDMGs() (err error) { switch d.Old.InputMode { case inputModeDirectory: log.Info("Mounting 'Old' patched OTA DMGs") if err := mountDirectoryDMGs(&d.Old); err != nil { return err } log.Info("Mounting 'New' patched OTA DMGs") if err := mountDirectoryDMGs(&d.New); err != nil { return err } return nil case inputModeOTA: log.Info("Extracting 'Old' OTA system cryptex") if err := mountOTACryptexes(&d.Old); err != nil { return err } log.Info("Extracting 'New' OTA system cryptex") if err := mountOTACryptexes(&d.New); err != nil { unmountOTACryptexes("Old", &d.Old) return err } return nil default: log.Info("Mounting 'Old' SystemOS DMG") if err := mountDMG(&d.Old); err != nil { return err } log.Info("Mounting 'New' SystemOS DMG") if err := mountDMG(&d.New); err != nil { return err } return nil } } func (d *Diff) unmountSystemOsDMGs() error { if d.Old.InputMode == inputModeDirectory { releaseDirectoryMounts("Old", d.Old.Mount) releaseDirectoryMounts("New", d.New.Mount) return nil } if d.Old.InputMode == inputModeOTA { unmountOTACryptexes("Old", &d.Old) unmountOTACryptexes("New", &d.New) return nil } utils.Indent(log.Info, 2)("Unmounting 'Old' SystemOS DMG") if err := utils.Retry(3, 2*time.Second, func() error { return utils.Unmount(d.Old.MountPath, true) }); err != nil { utils.Indent(log.Error, 3)(fmt.Sprintf("failed to unmount 'Old' SystemOS DMG: %v", err)) } utils.Indent(log.Info, 2)("Unmounting 'New' SystemOS DMG") if err := utils.Retry(3, 2*time.Second, func() error { return utils.Unmount(d.New.MountPath, true) }); err != nil { utils.Indent(log.Error, 3)(fmt.Sprintf("failed to unmount 'New' SystemOS DMG: %v", err)) } utils.Indent(log.Info, 2)("Deleting 'Old' SystemOS DMG") os.Remove(d.Old.SystemOsDmgPath) utils.Indent(log.Info, 2)("Deleting 'New' SystemOS DMG") os.Remove(d.New.SystemOsDmgPath) return nil } func (d *Diff) extractKernelcaches() error { if d.Old.InputMode == inputModeOTA { oldMember, newMember, err := selectOTAKernelcachePair(&d.Old, &d.New) if err != nil { return fmt.Errorf("failed to select OTA kernelcache pair: %w", err) } if err := extractOTAKernelcache(&d.Old, oldMember, d.Old.Folder); err != nil { return fmt.Errorf("failed to extract 'Old' OTA kernelcache: %w", err) } if err := extractOTAKernelcache(&d.New, newMember, d.New.Folder); err != nil { return fmt.Errorf("failed to extract 'New' OTA kernelcache: %w", err) } return nil } // IPSW mode. if d.Old.IsMacOS || d.New.IsMacOS { if out, err := kernelcache.Extract(d.Old.IPSWPath, d.Old.Folder, "Macmini9,1"); err != nil { return fmt.Errorf("failed to extract kernelcaches from 'Old' IPSW: %v", err) } else { d.Old.Kernel.Path = maps.Keys(out)[0] } if out, err := kernelcache.Extract(d.New.IPSWPath, d.New.Folder, "Macmini9,1"); err != nil { return fmt.Errorf("failed to extract kernelcaches from 'New' IPSW: %v", err) } else { d.New.Kernel.Path = maps.Keys(out)[0] } } else { if _, err := kernelcache.Extract(d.Old.IPSWPath, d.Old.Folder, ""); err != nil { return fmt.Errorf("failed to extract kernelcaches from 'Old' IPSW: %v", err) } if _, err := kernelcache.Extract(d.New.IPSWPath, d.New.Folder, ""); err != nil { return fmt.Errorf("failed to extract kernelcaches from 'New' IPSW: %v", err) } for kmodel := range d.Old.Info.Plists.GetKernelCaches() { if _, ok := d.Old.Info.Plists.GetKernelCaches()[kmodel]; !ok { return fmt.Errorf("failed to find kernelcache for %s in 'Old' IPSW: `ipsw diff` expects you to compare 2 versions of the same IPSW device type", kmodel) } else if len(d.Old.Info.Plists.GetKernelCaches()[kmodel]) == 0 { return fmt.Errorf("failed to find kernelcache for %s in 'Old' IPSW", kmodel) } if _, ok := d.New.Info.Plists.GetKernelCaches()[kmodel]; !ok { return fmt.Errorf("failed to find kernelcache for %s in 'New' IPSW: `ipsw diff` expects you to compare 2 versions of the same IPSW device type", kmodel) } else if len(d.New.Info.Plists.GetKernelCaches()[kmodel]) == 0 { return fmt.Errorf("failed to find kernelcache for %s in 'New' IPSW", kmodel) } kcache1 := d.Old.Info.Plists.GetKernelCaches()[kmodel][0] kcache2 := d.New.Info.Plists.GetKernelCaches()[kmodel][0] d.Old.Kernel.Path = filepath.Join(d.Old.Folder, d.Old.Info.GetKernelCacheFileName(kcache1)) d.New.Kernel.Path = filepath.Join(d.New.Folder, d.New.Info.GetKernelCacheFileName(kcache2)) break // just use first kernelcache for now } } return nil } func (d *Diff) parseKernelcache() error { if err := d.extractKernelcaches(); err != nil { return err } m1, err := macho.Open(d.Old.Kernel.Path) if err != nil { return fmt.Errorf("failed to open kernelcache: %v", err) } d.Old.Kernel.Version, err = kernelcache.GetVersion(m1) if err != nil { return fmt.Errorf("failed to get kernelcache version: %v", err) } defer m1.Close() m2, err := macho.Open(d.New.Kernel.Path) if err != nil { return fmt.Errorf("failed to open kernelcache: %v", err) } d.New.Kernel.Version, err = kernelcache.GetVersion(m2) if err != nil { return fmt.Errorf("failed to get kernelcache version: %v", err) } defer m2.Close() var smap map[string]signature.SymbolMap if d.conf.Signatures != "" { smap = make(map[string]signature.SymbolMap) log.Info("Parsing Kernel Signatures") sigs, err := signature.Parse(d.conf.Signatures) if err != nil { return fmt.Errorf("failed to parse signatures: %v", err) } smap[m1.UUID().String()] = signature.NewSymbolMap() log.WithField("kernelcache", d.Old.Kernel.Path).Info("Symbolicating...") if err := smap[m1.UUID().String()].Symbolicate(d.Old.Kernel.Path, sigs, true); err != nil { log.Errorf("failed to symbolicate kernelcache: %v", err) } smap[m2.UUID().String()] = signature.NewSymbolMap() log.WithField("kernelcache", d.New.Kernel.Path).Info("Symbolicating...") if err := smap[m2.UUID().String()].Symbolicate(d.New.Kernel.Path, sigs, true); err != nil { log.Errorf("failed to symbolicate kernelcache: %v", err) } } d.Kexts, err = kcmd.Diff(m1, m2, &mcmd.DiffConfig{ Markdown: true, Color: false, DiffTool: "git", AllowList: d.conf.AllowList, BlockList: d.conf.BlockList, CStrings: d.conf.CStrings, FuncStarts: d.conf.FuncStarts, SymMap: smap, Verbose: d.conf.Verbose, }) if err != nil { return err } // // diff kexts // d.Old.Kernel.Kexts, err = kernelcache.KextList(d.Old.Kernel.Path, true) // if err != nil { // return err // } // d.New.Kernel.Kexts, err = kernelcache.KextList(d.New.Kernel.Path, true) // if err != nil { // return err // } // out, err := utils.GitDiff( // strings.Join(d.Old.Kernel.Kexts, "\n")+"\n", // strings.Join(d.New.Kernel.Kexts, "\n")+"\n", // &utils.GitDiffConfig{Color: false, Tool: "git"}) // if err != nil { // return err // } // if len(out) == 0 { // d.Kexts = "- No differences found" // } else { // d.Kexts = "```diff\n" + out + "\n```" // } return nil } func (d *Diff) parseKDKs() (err error) { if !strings.Contains(d.Old.KDK, ".dSYM/Contents/Resources/DWARF") { d.Old.KDK = filepath.Join(d.Old.KDK+".dSYM/Contents/Resources/DWARF", filepath.Base(d.Old.KDK)) } if !strings.Contains(d.New.KDK, ".dSYM/Contents/Resources/DWARF") { d.New.KDK = filepath.Join(d.New.KDK+".dSYM/Contents/Resources/DWARF", filepath.Base(d.New.KDK)) } d.KDKs, err = dwarf.DiffStructures(d.Old.KDK, d.New.KDK, &dwarf.Config{ Markdown: true, Color: false, DiffTool: "git", }) d.Old.KDK, _, _ = strings.Cut(strings.TrimPrefix(d.Old.KDK, "/Library/Developer/KDKs/"), ".dSYM/Contents/Resources/DWARF") d.New.KDK, _, _ = strings.Cut(strings.TrimPrefix(d.New.KDK, "/Library/Developer/KDKs/"), ".dSYM/Contents/Resources/DWARF") return } func (d *Diff) parseDSC() error { /* OLD DSC */ oldDSCes, err := dyld.GetDscPathsInMount(d.Old.MountPath, false, false) if err != nil { return fmt.Errorf("failed to get DSC paths in %s: %v", d.Old.MountPath, err) } if len(oldDSCes) == 0 { return fmt.Errorf("no DSCs found in 'Old' IPSW mount %s", d.Old.MountPath) } if d.Old.IsMacOS { var filtered []string r := regexp.MustCompile(fmt.Sprintf("%s(%s)%s", dyld.CacheRegex, "arm64e", dyld.CacheRegexEnding)) for _, match := range oldDSCes { if r.MatchString(match) { filtered = append(filtered, match) } } if len(filtered) == 0 && d.Old.InputMode != inputModeOTA { return fmt.Errorf("no dyld_shared_cache files found matching the specified archs 'arm64e'") } if len(filtered) > 0 { oldDSCes = filtered } } dscOLD, err := dyld.Open(oldDSCes[0]) if err != nil { return fmt.Errorf("failed to open DSC: %v", err) } defer dscOLD.Close() /* NEW DSC */ newDSCes, err := dyld.GetDscPathsInMount(d.New.MountPath, false, false) if err != nil { return fmt.Errorf("failed to get DSC paths in %s: %v", d.New.MountPath, err) } if len(newDSCes) == 0 { return fmt.Errorf("no DSCs found in 'New' IPSW mount %s", d.New.MountPath) } if d.New.IsMacOS { var filtered []string r := regexp.MustCompile(fmt.Sprintf("%s(%s)%s", dyld.CacheRegex, "arm64e", dyld.CacheRegexEnding)) for _, match := range newDSCes { if r.MatchString(match) { filtered = append(filtered, match) } } if len(filtered) == 0 && d.New.InputMode != inputModeOTA { return fmt.Errorf("no dyld_shared_cache files found matching the specified archs 'arm64e'") } if len(filtered) > 0 { newDSCes = filtered } } dscNEW, err := dyld.Open(newDSCes[0]) if err != nil { return fmt.Errorf("failed to open DSC: %v", err) } defer dscNEW.Close() /* DIFF WEBKIT*/ d.Old.Webkit, err = dcmd.GetWebkitVersion(dscOLD) if err != nil { log.WithError(err).Error("failed to get WebKit version from 'old' DSC") } d.New.Webkit, err = dcmd.GetWebkitVersion(dscNEW) if err != nil { log.WithError(err).Error("failed to get WebKit version from 'new' DSC") } d.Dylibs, err = dcmd.Diff(dscOLD, dscNEW, &mcmd.DiffConfig{ Markdown: true, Color: false, DiffTool: "git", AllowList: d.conf.AllowList, BlockList: d.conf.BlockList, CStrings: d.conf.CStrings, FuncStarts: d.conf.FuncStarts, Verbose: d.conf.Verbose, }) if err != nil { return err } return nil } func scanEntitlementsFromMounts(mounts map[string]mount, label string) (map[string]string, error) { db := make(map[string]string) for _, name := range sortedMountNames(mounts) { mnt := mounts[name] partial, err := ent.GetDatabase(&ent.Config{ Folder: mnt.MountPath, LaunchConstraints: true, }) if err != nil { return nil, fmt.Errorf("failed to scan entitlements in %s %s: %w", label, name, err) } maps.Copy(db, partial) } return db, nil } func (d *Diff) ensureOTAPayloadFilesystems() error { if err := ensureOTAPayloadFilesystem(&d.Old); err != nil { return fmt.Errorf("failed to extract old OTA payload filesystem: %w", err) } if err := ensureOTAPayloadFilesystem(&d.New); err != nil { return fmt.Errorf("failed to extract new OTA payload filesystem: %w", err) } return nil } func (d *Diff) parseEntitlements() (string, error) { if d.Old.InputMode == inputModeOTA { if err := d.ensureOTAPayloadFilesystems(); err != nil { return "", err } oldDB, err := scanEntitlementsFromMounts(otaDiffMounts(&d.Old), "old") if err != nil { return "", err } newDB, err := scanEntitlementsFromMounts(otaDiffMounts(&d.New), "new") if err != nil { return "", err } return ent.DiffDatabases(oldDB, newDB, &ent.Config{ Markdown: true, Color: false, DiffTool: "git", }) } if d.Old.InputMode == inputModeDirectory { oldDB, err := scanEntitlementsFromMounts(d.Old.Mount, "old") if err != nil { return "", err } newDB, err := scanEntitlementsFromMounts(d.New.Mount, "new") if err != nil { return "", err } return ent.DiffDatabases(oldDB, newDB, &ent.Config{ Markdown: true, Color: false, DiffTool: "git", }) } oldDB, err := ent.GetDatabase(&ent.Config{ IPSW: d.Old.IPSWPath, PemDB: d.conf.PemDB, LaunchConstraints: true, }) if err != nil { return "", err } newDB, err := ent.GetDatabase(&ent.Config{ IPSW: d.New.IPSWPath, PemDB: d.conf.PemDB, LaunchConstraints: true, }) if err != nil { return "", err } return ent.DiffDatabases(oldDB, newDB, &ent.Config{ Markdown: true, Color: false, DiffTool: "git", }) } func (d *Diff) parseMachos() (err error) { conf := &mcmd.DiffConfig{ Markdown: true, Color: false, DiffTool: "git", AllowList: d.conf.AllowList, BlockList: d.conf.BlockList, CStrings: d.conf.CStrings, FuncStarts: d.conf.FuncStarts, Verbose: d.conf.Verbose, } if d.Old.InputMode == inputModeOTA { if err := d.ensureOTAPayloadFilesystems(); err != nil { return err } d.Machos, err = diffMachosInMounts(otaDiffMounts(&d.Old), otaDiffMounts(&d.New), conf) return } if d.Old.InputMode == inputModeDirectory { d.Machos, err = diffMachosInMounts(d.Old.Mount, d.New.Mount, conf) return } conf.LowMemory = d.conf.LowMemory d.Machos, err = mcmd.DiffIPSW(d.Old.IPSWPath, d.New.IPSWPath, conf) return } func (d *Diff) parseLaunchdPlists() error { if d.Old.InputMode == inputModeOTA { if err := d.ensureOTAPayloadFilesystems(); err != nil { return fmt.Errorf("diff: parseLaunchdPlists: %v", err) } oldConfig, err := launchdConfigFromRoots(otaLaunchdSearchRoots(&d.Old)) if err != nil { return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'Old' launchd config: %v", err) } newConfig, err := launchdConfigFromRoots(otaLaunchdSearchRoots(&d.New)) if err != nil { return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'New' launchd config: %v", err) } out, err := utils.GitDiff( oldConfig+"\n", newConfig+"\n", &utils.GitDiffConfig{Color: false, Tool: "git"}) if err != nil { return err } if len(out) > 0 { d.Launchd = "```diff\n" + out + "\n```" } return nil } if d.Old.InputMode != inputModeIPSW { return fmt.Errorf("diff: parseLaunchdPlists: launchd diff is only supported for IPSW and OTA payload inputs") } oldConfig, err := extract.LaunchdConfig(d.Old.IPSWPath, d.conf.PemDB) if err != nil { return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'Old' launchd config: %v", err) } newConfig, err := extract.LaunchdConfig(d.New.IPSWPath, d.conf.PemDB) if err != nil { return fmt.Errorf("diff: parseLaunchdPlists: failed to get 'New' launchd config: %v", err) } out, err := utils.GitDiff( oldConfig+"\n", newConfig+"\n", &utils.GitDiffConfig{Color: false, Tool: "git"}) if err != nil { return err } if len(out) > 0 { d.Launchd = "```diff\n" + out + "\n```" } return nil } func (d *Diff) parseFirmwares() (err error) { conf := &mcmd.DiffConfig{ Markdown: true, Color: false, DiffTool: "git", AllowList: d.conf.AllowList, BlockList: d.conf.BlockList, CStrings: d.conf.CStrings, FuncStarts: d.conf.FuncStarts, Verbose: d.conf.Verbose, LowMemory: d.conf.LowMemory, } if d.Old.InputMode == inputModeOTA { d.Firmwares, err = diffFirmwaresFromOTA(&d.Old, &d.New, conf) return } if d.Old.InputMode == inputModeDirectory { log.Warn("Firmware diff (--fw) not supported for directory inputs; skipping") return nil } d.Firmwares, err = mcmd.DiffFirmwares(d.Old.IPSWPath, d.New.IPSWPath, conf) return } func (d *Diff) parseIBoot() (err error) { d.IBoot = &IBootDiff{ New: make(map[string][]string), Removed: make(map[string][]string), } var oldIBoot, newIBoot *iboot.IBoot if d.Old.InputMode == inputModeOTA { oldIBoot, err = parseOTAIBoot(&d.Old) if err != nil { return fmt.Errorf("failed to get iBoot from 'Old' OTA: %v", err) } newIBoot, err = parseOTAIBoot(&d.New) if err != nil { return fmt.Errorf("failed to get iBoot from 'New' OTA: %v", err) } } else { tmpDIR, err := os.MkdirTemp("", "ipsw_extract_iboot") if err != nil { return fmt.Errorf("failed to create temporary directory to store im4ps: %v", err) } defer os.RemoveAll(tmpDIR) getIboot := func(ipswPath string) (*iboot.IBoot, error) { iBootIm4ps, err := utils.Unzip(ipswPath, tmpDIR, func(f *zip.File) bool { // return regexp.MustCompile(`iBSS.*\.im4p$`).MatchString(f.Name) || regexp.MustCompile(`iBoot\..*\.im4p$`).MatchString(f.Name) return regexp.MustCompile(`iBoot\..*\.im4p$`).MatchString(f.Name) }) if err != nil { return nil, fmt.Errorf("failed to unzip iBoot im4p: %v", err) } im4p, err := img4.OpenPayload(iBootIm4ps[0]) if err != nil { return nil, fmt.Errorf("failed to open im4p: %v", err) } return iboot.Parse(im4p.Data) } oldIBoot, err = getIboot(d.Old.IPSWPath) if err != nil { return fmt.Errorf("failed to get iBoot from 'Old' IPSW: %v", err) } newIBoot, err = getIboot(d.New.IPSWPath) if err != nil { return fmt.Errorf("failed to get iBoot from 'New' IPSW: %v", err) } } d.IBoot.Versions = []string{oldIBoot.Version, newIBoot.Version} for name, strs := range newIBoot.Strings { if _, ok := oldIBoot.Strings[name]; ok { for _, str := range strs { if len(str) < 10 { continue } found := false for _, oldStr := range oldIBoot.Strings[name] { if str == oldStr { found = true break } } if !found { d.IBoot.New[name] = append(d.IBoot.New[name], str) } } } else { for _, str := range strs { d.IBoot.New[name] = append(d.IBoot.New[name], str) } } } for name, strs := range oldIBoot.Strings { if _, ok := newIBoot.Strings[name]; ok { for _, str := range strs { if len(str) < 10 { continue } found := false for _, newStr := range newIBoot.Strings[name] { if str == newStr { found = true break } } if !found { d.IBoot.Removed[name] = append(d.IBoot.Removed[name], str) } } } } return nil } func (d *Diff) parseFeatureFlags() (err error) { d.Features = &PlistDiff{ New: make(map[string]string), Updated: make(map[string]string), } conf := &mcmd.DiffConfig{ Markdown: true, Color: false, DiffTool: "git", } oldPlists := make(map[string]string) newPlists := make(map[string]string) if d.Old.InputMode == inputModeOTA { if err := d.ensureOTAPayloadFilesystems(); err != nil { return err } if err := collectFeatureFlagsFromMounts(otaDiffMounts(&d.Old), oldPlists); err != nil { return err } if err := collectFeatureFlagsFromMounts(otaDiffMounts(&d.New), newPlists); err != nil { return err } } else if d.Old.InputMode == inputModeDirectory { if err := collectFeatureFlagsFromMounts(d.Old.Mount, oldPlists); err != nil { return err } if err := collectFeatureFlagsFromMounts(d.New.Mount, newPlists); err != nil { return err } } else { if err := search.ForEachPlistInIPSW(d.Old.IPSWPath, "/System/Library/FeatureFlags", d.conf.PemDB, func(path string, content string) error { oldPlists[path] = content return nil }); err != nil { return err } if err := search.ForEachPlistInIPSW(d.New.IPSWPath, "/System/Library/FeatureFlags", d.conf.PemDB, func(path string, content string) error { newPlists[path] = content return nil }); err != nil { return err } } var prevFiles []string for f := range oldPlists { prevFiles = append(prevFiles, f) } slices.Sort(prevFiles) var nextFiles []string for f := range newPlists { nextFiles = append(nextFiles, f) } slices.Sort(nextFiles) /* DIFF IPSW */ newFiles := utils.Difference(nextFiles, prevFiles) d.Features.Removed = utils.Difference(prevFiles, nextFiles) for _, f2 := range nextFiles { if slices.Contains(newFiles, f2) { d.Features.New[f2] = newPlists[f2] } dat2 := newPlists[f2] if dat1, ok := oldPlists[f2]; ok { if strings.EqualFold(dat2, dat1) { continue } var out string if conf.Markdown { out, err = utils.GitDiff(dat1+"\n", dat2+"\n", &utils.GitDiffConfig{Color: conf.Color, Tool: conf.DiffTool}) if err != nil { return err } } else { out, err = utils.GitDiff(dat1+"\n", dat2+"\n", &utils.GitDiffConfig{Color: conf.Color, Tool: conf.DiffTool}) if err != nil { return err } } if len(out) == 0 { // no diff continue } if conf.Markdown { d.Features.Updated[f2] = "```diff\n" + out + "\n```\n" } else { d.Features.Updated[f2] = out } } } return nil } func (d *Diff) parseFiles() error { if d.Old.InputMode == inputModeOTA { if err := d.ensureOTAPayloadFilesystems(); err != nil { return err } var err error d.Files, err = diffFilesInMounts(otaDiffMounts(&d.Old), otaDiffMounts(&d.New)) return err } if d.Old.InputMode == inputModeDirectory { var err error d.Files, err = diffFilesInMounts(d.Old.Mount, d.New.Mount) return err } d.Files = &FileDiff{ New: make(map[string][]string), Removed: make(map[string][]string), } /* PREVIOUS IPSW */ prev := make(map[string][]string) if err := search.ForEachFileInIPSW(d.Old.IPSWPath, "", d.conf.PemDB, func(dmg, path string) error { prev[dmg] = append(prev[dmg], path) return nil }); err != nil { return err } /* NEXT IPSW */ next := make(map[string][]string) if err := search.ForEachFileInIPSW(d.New.IPSWPath, "", d.conf.PemDB, func(dmg, path string) error { next[dmg] = append(next[dmg], path) return nil }); err != nil { return err } for dmg := range prev { d.Files.New[dmg] = utils.Difference(next[dmg], prev[dmg]) d.Files.Removed[dmg] = utils.Difference(prev[dmg], next[dmg]) sort.Strings(d.Files.New[dmg]) sort.Strings(d.Files.Removed[dmg]) } return nil }