mirror of
https://github.com/blacktop/ipsw.git
synced 2026-06-07 12:27:36 +00:00
Refactor diff jobs into task-owned renderers with persistent cache support, root-confined walkers, and quieter Mach-O/Firmware output.
1447 lines
44 KiB
Go
1447 lines
44 KiB
Go
// Package diff provides a way to diff two ipsws
|
|
package diff
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
kcmd "github.com/blacktop/ipsw/internal/commands/kernel"
|
|
mcmd "github.com/blacktop/ipsw/internal/commands/macho"
|
|
mountcmd "github.com/blacktop/ipsw/internal/commands/mount"
|
|
"github.com/blacktop/ipsw/internal/diff/storage"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
"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
|
|
Localizations bool
|
|
Sandbox bool
|
|
CStrings bool
|
|
FuncStarts bool
|
|
Entitlements bool
|
|
AllowList []string
|
|
BlockList []string
|
|
PemDB string
|
|
Signatures string
|
|
Output string
|
|
Verbose bool
|
|
AEAKeyDB string
|
|
AEAKeyVal string
|
|
AEAInsecure bool
|
|
// Cache holds the persistent-cache lifecycle options used in IPSW
|
|
// mode. A zero value disables persistent caching and falls back to
|
|
// the per-orchestrator MemoryStore that the directory/OTA paths use.
|
|
Cache CacheConfig
|
|
}
|
|
|
|
// CacheConfig mirrors the CLI flags that govern the SQLite-backed diff
|
|
// cache. It is consumed by [Diff.Diff] after parsing IPSW Info structs;
|
|
// non-IPSW input modes ignore it.
|
|
type CacheConfig struct {
|
|
// Dir overrides the default cache directory. Empty means use
|
|
// os.UserCacheDir() + "/ipsw/diffs/".
|
|
Dir string
|
|
// NoCache disables persistent storage; a temp SQLite DB is used and
|
|
// removed on exit.
|
|
NoCache bool
|
|
// Clean deletes any existing cache DB for this pair before opening.
|
|
Clean bool
|
|
// MaxBytes is the LRU eviction threshold applied to the cache
|
|
// directory after the run finishes. Zero disables eviction.
|
|
MaxBytes int64
|
|
}
|
|
|
|
// 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 the parsed OTA archive. Nil unless InputMode == inputModeOTA.
|
|
otaFile *otapkg.AA
|
|
// payloadRoot is the extracted OTA payload filesystem root.
|
|
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 map[string]string `json:"ents,omitempty"`
|
|
Dylibs *mcmd.MachoDiff `json:"dylibs,omitempty"`
|
|
Machos map[string]*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 map[string]*PlistDiff `json:"features,omitempty"`
|
|
Files *FileDiff `json:"files,omitempty"`
|
|
Localizations map[string]*PlistDiff `json:"localizations,omitempty"`
|
|
tmpDir string `json:"-"`
|
|
conf *Config
|
|
oldSession *mountcmd.Session // IPSW mode only: mounts each OS volume once
|
|
newSession *mountcmd.Session // IPSW mode only: mounts each OS volume once
|
|
sameKernel bool // IPSW mode only: BuildManifest digests match
|
|
sameVolumes map[string]bool // IPSW mode only: dmg type -> BuildManifest digests match
|
|
// store is the cache backend used by orchestrators; nil means each
|
|
// orchestrator allocates its own ephemeral MemoryStore. The CLI sets
|
|
// this via SetStore so a single SQLiteStore spans every task in a run.
|
|
store storage.Store
|
|
}
|
|
|
|
// SetStore installs a shared cache backend used by the volume-major and
|
|
// top-level orchestrators. Passing nil restores the default behavior of
|
|
// allocating a per-run MemoryStore for each orchestrator call.
|
|
func (d *Diff) SetStore(s storage.Store) { d.store = s }
|
|
|
|
// 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
|
|
}
|
|
|
|
func ipswSessionExtractDir(tmpDir, side string) string {
|
|
return filepath.Join(tmpDir, "ipsw-session", side)
|
|
}
|
|
|
|
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
|
|
}
|
|
d.indexIdenticalIPSWArtifacts()
|
|
|
|
// Install the persistent diff cache for IPSW mode. Directory and
|
|
// OTA inputs lack BuildManifest digests so they fall back to the
|
|
// per-orchestrator MemoryStore that acquireStore allocates.
|
|
if cleanup := d.openCacheStore(); cleanup != nil {
|
|
defer cleanup()
|
|
}
|
|
|
|
// 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, ", "))
|
|
}
|
|
}
|
|
|
|
// Top-level kernel-derived tasks run in source order before mount
|
|
// sessions exist: parseKernelcache populates d.Kexts and Kernel.Path
|
|
// (which the sandbox task consumes); parseKDKs runs against the
|
|
// per-side KDK paths; parseSandboxProfiles is gated on sameKernel.
|
|
{
|
|
var pre []TopLevelTask
|
|
if directoryMode {
|
|
log.Debug("Skipping KERNELCACHES for directory inputs")
|
|
} else if d.sameKernel {
|
|
log.Info("Skipping KERNELCACHES (BuildManifest digest unchanged)")
|
|
} else {
|
|
log.Info("Diffing KERNELCACHES")
|
|
pre = append(pre, newKextsTask(d))
|
|
}
|
|
if d.Old.KDK != "" && d.New.KDK != "" {
|
|
log.Info("Diffing KDKS")
|
|
pre = append(pre, newKDKsTask(d))
|
|
}
|
|
if err := d.runTopLevelTasks(context.Background(), pre); err != nil {
|
|
log.WithError(err).Error("failed to run pre-mount top-level tasks")
|
|
}
|
|
}
|
|
|
|
if d.conf.Sandbox && !directoryMode {
|
|
if d.sameKernel {
|
|
utils.Indent(log.Warn, 2)("Skipping Sandbox Profiles (kernelcache unchanged)")
|
|
} else {
|
|
log.Info("Diffing Sandbox Profiles")
|
|
if err := d.runTopLevelTasks(context.Background(), []TopLevelTask{newSandboxTask(d)}); err != nil {
|
|
log.WithError(err).Error("failed to diff sandbox profiles")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !directoryMode && !otaMode {
|
|
// IPSW mode: mount each OS volume once per side via a session, shared by
|
|
// the DSC parse and every later feature. defer Close() here so it
|
|
// dominates every session.Root() call across the feature passes.
|
|
// Keep old/new extracted DMGs isolated; IPSWs often reuse the same DMG
|
|
// filenames across builds, and DmgInIPSW treats an existing extraction as
|
|
// a cache hit.
|
|
d.oldSession = mountcmd.NewSession(d.Old.IPSWPath, &mountcmd.Config{
|
|
PemDB: d.conf.PemDB,
|
|
ExtractDir: ipswSessionExtractDir(d.tmpDir, "old"),
|
|
})
|
|
d.newSession = mountcmd.NewSession(d.New.IPSWPath, &mountcmd.Config{
|
|
PemDB: d.conf.PemDB,
|
|
ExtractDir: ipswSessionExtractDir(d.tmpDir, "new"),
|
|
})
|
|
defer d.oldSession.Close()
|
|
defer d.newSession.Close()
|
|
// IPSW mode no longer pre-mounts "sys" here. The volume-major
|
|
// orchestrator (runIPSWVolumeJobsForMode) lazily mounts each volume
|
|
// when its phase runs and releases it before moving on.
|
|
} else if otaMode {
|
|
if err := d.mountSystemOsDMGs(); err != nil {
|
|
return fmt.Errorf("failed to mount DMGs: %v", err)
|
|
}
|
|
defer d.unmountSystemOsDMGs()
|
|
}
|
|
|
|
if directoryMode || otaMode {
|
|
// Legacy feature-major paths for non-IPSW input modes.
|
|
if otaMode || !d.dscVolumeUnchanged() {
|
|
log.Info("Diffing DYLD_SHARED_CACHES")
|
|
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 && otaMode {
|
|
log.Info("Diffing launchd PLIST")
|
|
if err := d.parseLaunchdPlists(); err != nil {
|
|
log.WithError(err).Error("failed to parse launchd plists")
|
|
}
|
|
}
|
|
}
|
|
// IPSW mode: DSC, MachOs, launchd, features, localizations, entitlements,
|
|
// and files are all dispatched by the volume-major orchestrator below.
|
|
|
|
if d.conf.Firmware {
|
|
var fw []TopLevelTask
|
|
log.Info("Diffing Firmware")
|
|
fw = append(fw, newFirmwaresTask(d))
|
|
if !directoryMode {
|
|
log.Info("Diffing iBoot")
|
|
fw = append(fw, newIBootTask(d))
|
|
}
|
|
if err := d.runTopLevelTasks(context.Background(), fw); err != nil {
|
|
log.WithError(err).Error("failed to run firmware top-level tasks")
|
|
}
|
|
}
|
|
|
|
// IPSW-mode Feature Flags is handled by featuresJob via the volume-major
|
|
// orchestrator (see runIPSWVolumeJobsForMode). OTA and Directory modes
|
|
// still run the legacy parseFeatureFlags here.
|
|
if d.conf.Features && (directoryMode || otaMode) {
|
|
log.Info("Diffing Feature Flags")
|
|
if err := d.parseFeatureFlags(); err != nil {
|
|
log.WithError(err).Error("failed to parse feature flags")
|
|
}
|
|
}
|
|
|
|
// IPSW-mode Localizations handled by locsJob via the orchestrator.
|
|
if d.conf.Localizations && (directoryMode || otaMode) {
|
|
log.Info("Diffing Localizations")
|
|
if err := d.parseLocalizations(); err != nil {
|
|
log.WithError(err).Error("failed to parse localizations")
|
|
}
|
|
}
|
|
|
|
// IPSW-mode Entitlements handled by entsJob via the orchestrator.
|
|
if d.conf.Entitlements && (directoryMode || otaMode) {
|
|
log.Info("Diffing ENTITLEMENTS")
|
|
d.Ents, err = d.parseEntitlements()
|
|
if err != nil {
|
|
log.WithError(err).Error("failed to parse entitlements")
|
|
}
|
|
}
|
|
|
|
if err := d.runIPSWVolumeJobsForMode(directoryMode, otaMode); err != nil {
|
|
log.WithError(err).Error("failed to run volume-major jobs")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runIPSWVolumeJobsForMode dispatches the per-flag jobs that have been
|
|
// migrated to the volume-major orchestrator. OTA and Directory modes fall
|
|
// back to the legacy feature-major parser for Files; the other parsers
|
|
// (DSC, launchd) are handled outside this function for those modes.
|
|
//
|
|
// IPSW mode builds a job list from d.conf.* flags and runs the orchestrator.
|
|
// As more parsers are ported, additional entries get appended here.
|
|
func (d *Diff) runIPSWVolumeJobsForMode(directoryMode, otaMode bool) error {
|
|
if directoryMode || otaMode {
|
|
if d.conf.Files {
|
|
log.Info("Diffing Files")
|
|
if err := d.parseFiles(); err != nil {
|
|
log.WithError(err).Error("failed to parse files")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var jobs []Task
|
|
|
|
// DSC always runs in IPSW mode (the existing behavior); the per-volume
|
|
// skip in the orchestrator handles the "sys unchanged" case.
|
|
if !d.dscVolumeUnchanged() {
|
|
log.Info("Diffing DYLD_SHARED_CACHES")
|
|
jobs = append(jobs, newDSCJob(d))
|
|
} else {
|
|
log.Info("Skipping DYLD_SHARED_CACHES (SystemOS DMG unchanged)")
|
|
}
|
|
|
|
if d.conf.LaunchD {
|
|
if d.ipswVolumeUnchanged("fs") {
|
|
log.Info("Skipping launchd PLIST (FileSystem DMG unchanged)")
|
|
} else {
|
|
log.Info("Diffing launchd PLIST")
|
|
jobs = append(jobs, newLaunchdJob(d))
|
|
}
|
|
}
|
|
|
|
if d.conf.Features {
|
|
if d.allIPSWOSVolumesUnchanged() {
|
|
log.Info("Skipping Feature Flags (OS DMGs unchanged)")
|
|
} else {
|
|
log.Info("Diffing Feature Flags")
|
|
jobs = append(jobs, newFeaturesJob(d))
|
|
}
|
|
}
|
|
|
|
if d.conf.Localizations {
|
|
if d.allIPSWOSVolumesUnchanged() {
|
|
log.Info("Skipping Localizations (OS DMGs unchanged)")
|
|
} else {
|
|
log.Info("Diffing Localizations")
|
|
jobs = append(jobs, newLocalizationsJob(d))
|
|
}
|
|
}
|
|
|
|
if d.conf.Entitlements {
|
|
if d.allIPSWOSVolumesUnchanged() {
|
|
log.Info("Skipping ENTITLEMENTS (OS DMGs unchanged)")
|
|
} else {
|
|
log.Info("Diffing ENTITLEMENTS")
|
|
jobs = append(jobs, newEntitlementsJob(d))
|
|
}
|
|
}
|
|
|
|
// MachOs always runs in IPSW mode (the existing behavior); the per-volume
|
|
// skip in the orchestrator handles the "all OS DMGs unchanged" case.
|
|
if d.allIPSWOSVolumesUnchanged() {
|
|
log.Info("Skipping MachOs (OS DMGs unchanged)")
|
|
} else {
|
|
log.Info("Diffing MachOs")
|
|
jobs = append(jobs, newMachosJob(d))
|
|
}
|
|
|
|
if d.conf.Files {
|
|
log.Info("Diffing Files")
|
|
jobs = append(jobs, newFilesJob(d))
|
|
}
|
|
|
|
if len(jobs) == 0 {
|
|
return nil
|
|
}
|
|
return d.runIPSWVolumePhases(jobs)
|
|
}
|
|
|
|
// mountSystemOsDMGs mounts the OS volumes for OTA and directory inputs. IPSW
|
|
// mode instead mounts each volume once via a per-side mount.Session in Diff().
|
|
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:
|
|
return fmt.Errorf("mountSystemOsDMGs: IPSW mode mounts via mount.Session, not this path")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
// IPSW mode unmounts via mount.Session.Close.
|
|
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
|
|
}
|
|
|
|
// ensureKernelcachePaths populates d.Old.Kernel.Path / d.New.Kernel.Path when
|
|
// they are empty. extractKernelcaches sets them as a side effect of the kexts
|
|
// task's Parse, but a warm cache hit on kexts SKIPS that Parse, so a sibling
|
|
// task that runs fresh (e.g. a partial-hit sandbox task) would otherwise find an
|
|
// empty path. Extraction treats an existing extraction as a cache hit, so this is
|
|
// idempotent and cheap; it is a no-op once either prior extraction has run.
|
|
func (d *Diff) ensureKernelcachePaths() error {
|
|
if d.Old.Kernel.Path != "" && d.New.Kernel.Path != "" {
|
|
return nil
|
|
}
|
|
return d.extractKernelcaches()
|
|
}
|
|
|
|
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()
|
|
|
|
sameKernel, err := filesSHA256Equal(d.Old.Kernel.Path, d.New.Kernel.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compare extracted kernelcaches: %w", err)
|
|
}
|
|
if sameKernel {
|
|
d.sameKernel = true
|
|
utils.Indent(log.Warn, 2)("Skipping kernelcache symbolication and KEXT diff (extracted kernelcache unchanged)")
|
|
return nil
|
|
}
|
|
|
|
// Wrapper bytes (UUID, build-root strings, embedded plist digests) often
|
|
// differ across rebuilds of an otherwise-identical kernel. If every
|
|
// functional segment (code, constants, mutable data, symbols) matches,
|
|
// the kernel is functionally unchanged regardless of those differences.
|
|
if kernelKeySegmentsEqual(m1, m2) {
|
|
d.sameKernel = true
|
|
utils.Indent(log.Warn, 2)("Skipping kernelcache symbolication and KEXT diff (functional segments unchanged; only UUID/build metadata differs)")
|
|
return nil
|
|
}
|
|
|
|
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) {
|
|
d.Old.KDK = kdkDwarfPath(d.Old.KDK)
|
|
d.New.KDK = kdkDwarfPath(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 = kdkDisplayPath(d.Old.KDK)
|
|
d.New.KDK = kdkDisplayPath(d.New.KDK)
|
|
return
|
|
}
|
|
|
|
func (d *Diff) parseDSC() error {
|
|
return d.diffDSCBetweenRoots(d.Old.MountPath, d.New.MountPath)
|
|
}
|
|
|
|
// diffDSCBetweenRoots is the shared DSC diff implementation used by both
|
|
// the legacy parseDSC (OTA / Directory mode, which sets Context.MountPath
|
|
// via their own mount setup) and the volume-major dscJob (IPSW mode, which
|
|
// receives roots from the orchestrator's "sys" phase).
|
|
func (d *Diff) diffDSCBetweenRoots(oldRoot, newRoot string) error {
|
|
dscOLD, err := openDSCFromMount(oldRoot, d.Old.IsMacOS, d.Old.InputMode, "Old")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dscOLD.Close()
|
|
|
|
dscNEW, err := openDSCFromMount(newRoot, d.New.IsMacOS, d.New.InputMode, "New")
|
|
if err != nil {
|
|
return 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, d.dscDiffConfig())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// openDSCFromMount finds the dyld_shared_cache under mountRoot (filtered to
|
|
// arm64e for macOS IPSWs when applicable) and opens the first match.
|
|
func openDSCFromMount(mountRoot string, isMacOS bool, mode inputMode, side string) (*dyld.File, error) {
|
|
dscs, err := dyld.GetDscPathsInMount(mountRoot, false, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get DSC paths in %s: %v", mountRoot, err)
|
|
}
|
|
if len(dscs) == 0 {
|
|
return nil, fmt.Errorf("no DSCs found in '%s' IPSW mount %s", side, mountRoot)
|
|
}
|
|
if isMacOS {
|
|
var filtered []string
|
|
r := regexp.MustCompile(fmt.Sprintf("%s(%s)%s", dyld.CacheRegex, "arm64e", dyld.CacheRegexEnding))
|
|
for _, match := range dscs {
|
|
if r.MatchString(match) {
|
|
filtered = append(filtered, match)
|
|
}
|
|
}
|
|
if len(filtered) == 0 && mode != inputModeOTA {
|
|
return nil, fmt.Errorf("no dyld_shared_cache files found matching the specified archs 'arm64e'")
|
|
}
|
|
if len(filtered) > 0 {
|
|
dscs = filtered
|
|
}
|
|
}
|
|
dsc, err := dyld.Open(dscs[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open DSC: %v", err)
|
|
}
|
|
return dsc, 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
|
|
}
|
|
|
|
// parseEntitlements handles OTA and Directory input modes. IPSW mode is
|
|
// handled by entsJob via the volume-major orchestrator.
|
|
func (d *Diff) parseEntitlements() (map[string]string, error) {
|
|
var oldMounts, newMounts map[string]mount
|
|
switch d.Old.InputMode {
|
|
case inputModeOTA:
|
|
if err := d.ensureOTAPayloadFilesystems(); err != nil {
|
|
return nil, err
|
|
}
|
|
oldMounts = otaDiffMounts(&d.Old)
|
|
newMounts = otaDiffMounts(&d.New)
|
|
case inputModeDirectory:
|
|
oldMounts = d.Old.Mount
|
|
newMounts = d.New.Mount
|
|
default:
|
|
return nil, fmt.Errorf("diff: parseEntitlements: IPSW mode uses entsJob via the volume-major orchestrator")
|
|
}
|
|
|
|
names := unionMountNames(oldMounts, newMounts)
|
|
out := make(map[string]string)
|
|
for _, name := range names {
|
|
oldDB := make(map[string]string)
|
|
if mnt, ok := oldMounts[name]; ok {
|
|
ents, err := ent.GetDatabase(&ent.Config{
|
|
Folder: mnt.MountPath,
|
|
LaunchConstraints: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan entitlements in old %s: %w", name, err)
|
|
}
|
|
maps.Copy(oldDB, ents)
|
|
}
|
|
newDB := make(map[string]string)
|
|
if mnt, ok := newMounts[name]; ok {
|
|
ents, err := ent.GetDatabase(&ent.Config{
|
|
Folder: mnt.MountPath,
|
|
LaunchConstraints: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan entitlements in new %s: %w", name, err)
|
|
}
|
|
maps.Copy(newDB, ents)
|
|
}
|
|
rendered, err := renderEntitlementsDiff(oldDB, newDB)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if entitlementsDiffHasContent(rendered) {
|
|
out[name] = rendered
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// entitlementsDiffHasContent reports whether the rendered diff string
|
|
// contains actual diff data (as opposed to the "no differences" marker).
|
|
func entitlementsDiffHasContent(s string) bool {
|
|
return s != "" && s != "- No differences found\n"
|
|
}
|
|
|
|
// renderEntitlementsDiff renders the markdown diff between two
|
|
// path-keyed entitlement databases. Shared by parseEntitlements
|
|
// (OTA/Directory) and entsJob (IPSW).
|
|
func renderEntitlementsDiff(oldDB, newDB map[string]string) (string, error) {
|
|
return ent.DiffDatabases(oldDB, newDB, &ent.Config{
|
|
Markdown: true,
|
|
Color: false,
|
|
DiffTool: "git",
|
|
})
|
|
}
|
|
|
|
// parseMachos handles OTA and Directory input modes. IPSW mode is handled
|
|
// by machosJob via the volume-major orchestrator.
|
|
func (d *Diff) parseMachos() (err error) {
|
|
conf := d.machoDiffConfig()
|
|
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
|
|
}
|
|
return fmt.Errorf("diff: parseMachos: IPSW mode uses machosJob via the volume-major orchestrator")
|
|
}
|
|
|
|
// machoDiffConfig builds the mcmd.DiffConfig used by all macho diff paths.
|
|
// Shared by parseMachos (OTA/Directory) and machosJob (IPSW).
|
|
func (d *Diff) machoDiffConfig() *mcmd.DiffConfig {
|
|
return &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,
|
|
}
|
|
}
|
|
|
|
// dscDiffConfig builds the mcmd.DiffConfig used for dyld_shared_cache image
|
|
// diffs. Cache-image load-command bytes are noisy across point releases even
|
|
// when the image's rendered sections are unchanged, so DSC reports rely on the
|
|
// section/function/string/symbol legs and suppress load-command comparison.
|
|
func (d *Diff) dscDiffConfig() *mcmd.DiffConfig {
|
|
conf := d.machoDiffConfig()
|
|
conf.IgnoreLoadCommands = true
|
|
return conf
|
|
}
|
|
|
|
// parseLaunchdPlists handles the OTA path (Directory mode is not supported
|
|
// for launchd). IPSW mode is handled by launchdJob via the volume-major
|
|
// orchestrator.
|
|
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)
|
|
}
|
|
return d.applyLaunchdGitDiff(oldConfig, newConfig)
|
|
}
|
|
if d.Old.InputMode != inputModeIPSW {
|
|
return fmt.Errorf("diff: parseLaunchdPlists: launchd diff is only supported for IPSW and OTA payload inputs")
|
|
}
|
|
return fmt.Errorf("diff: parseLaunchdPlists: IPSW mode uses launchdJob via the volume-major orchestrator")
|
|
}
|
|
|
|
// applyLaunchdGitDiff renders the git diff of the two extracted launchd
|
|
// __TEXT.__config strings into d.Launchd. Shared by parseLaunchdPlists
|
|
// (OTA) and launchdJob (IPSW).
|
|
func (d *Diff) applyLaunchdGitDiff(oldConfig, newConfig string) error {
|
|
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,
|
|
}
|
|
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
|
|
}
|
|
|
|
// parseFeatureFlags handles OTA and Directory input modes. IPSW mode is
|
|
// handled by featuresJob via the volume-major orchestrator.
|
|
func (d *Diff) parseFeatureFlags() error {
|
|
var oldMounts, newMounts map[string]mount
|
|
switch d.Old.InputMode {
|
|
case inputModeOTA:
|
|
if err := d.ensureOTAPayloadFilesystems(); err != nil {
|
|
return err
|
|
}
|
|
oldMounts = otaDiffMounts(&d.Old)
|
|
newMounts = otaDiffMounts(&d.New)
|
|
case inputModeDirectory:
|
|
oldMounts = d.Old.Mount
|
|
newMounts = d.New.Mount
|
|
default:
|
|
return fmt.Errorf("diff: parseFeatureFlags: IPSW mode uses featuresJob via the volume-major orchestrator")
|
|
}
|
|
|
|
prevByVolume := make(map[string]map[string]string)
|
|
nextByVolume := make(map[string]map[string]string)
|
|
volumes, err := collectFeatureFlagsByVolume(oldMounts, prevByVolume, newMounts, nextByVolume)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := buildPlistDiffByVolume(volumes, prevByVolume, nextByVolume)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.Features = out
|
|
return nil
|
|
}
|
|
|
|
// collectFeatureFlagsByVolume walks System/Library/FeatureFlags under every
|
|
// mount on both sides, populating per-volume plist maps. Returns the union
|
|
// of volume names in sorted order so callers can build a deterministic
|
|
// output.
|
|
func collectFeatureFlagsByVolume(oldMounts map[string]mount, prev map[string]map[string]string, newMounts map[string]mount, next map[string]map[string]string) ([]string, error) {
|
|
names := unionMountNames(oldMounts, newMounts)
|
|
for _, name := range names {
|
|
if mnt, ok := oldMounts[name]; ok {
|
|
prev[name] = make(map[string]string)
|
|
if err := collectFeatureFlagsFromMount(mnt.MountPath, name, prev[name]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if mnt, ok := newMounts[name]; ok {
|
|
next[name] = make(map[string]string)
|
|
if err := collectFeatureFlagsFromMount(mnt.MountPath, name, next[name]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// trackVolumeOnce appends label to volumes if not already present.
|
|
func trackVolumeOnce(volumes *[]string, label string) {
|
|
if slices.Contains(*volumes, label) {
|
|
return
|
|
}
|
|
*volumes = append(*volumes, label)
|
|
}
|
|
|
|
func removeVolume(volumes *[]string, label string) {
|
|
out := (*volumes)[:0]
|
|
for _, v := range *volumes {
|
|
if v != label {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
*volumes = out
|
|
}
|
|
|
|
// buildPlistDiffByVolume builds the per-volume PlistDiff map. Empty
|
|
// per-volume diffs are omitted. Shared by featuresJob and locsJob.
|
|
func buildPlistDiffByVolume(volumes []string, prev, next map[string]map[string]string) (map[string]*PlistDiff, error) {
|
|
out := make(map[string]*PlistDiff, len(volumes))
|
|
for _, vol := range volumes {
|
|
diff, err := buildPlistDiff(prev[vol], next[vol])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if plistDiffHasContent(diff) {
|
|
out[vol] = diff
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func plistDiffHasContent(d *PlistDiff) bool {
|
|
return d != nil && (len(d.New) > 0 || len(d.Removed) > 0 || len(d.Updated) > 0)
|
|
}
|
|
|
|
// buildPlistDiff computes a PlistDiff from path-keyed old/new plist content
|
|
// maps. Shared by feature-flag and localization paths.
|
|
func buildPlistDiff(oldPlists, newPlists map[string]string) (*PlistDiff, error) {
|
|
diff := &PlistDiff{
|
|
New: make(map[string]string),
|
|
Updated: make(map[string]string),
|
|
}
|
|
|
|
prevFiles := make([]string, 0, len(oldPlists))
|
|
for f := range oldPlists {
|
|
prevFiles = append(prevFiles, f)
|
|
}
|
|
slices.Sort(prevFiles)
|
|
|
|
nextFiles := make([]string, 0, len(newPlists))
|
|
for f := range newPlists {
|
|
nextFiles = append(nextFiles, f)
|
|
}
|
|
slices.Sort(nextFiles)
|
|
|
|
newFiles := utils.Difference(nextFiles, prevFiles)
|
|
diff.Removed = utils.Difference(prevFiles, nextFiles)
|
|
|
|
for _, f2 := range nextFiles {
|
|
dat2 := newPlists[f2]
|
|
if slices.Contains(newFiles, f2) {
|
|
diff.New[f2] = dat2
|
|
continue
|
|
}
|
|
dat1, ok := oldPlists[f2]
|
|
if !ok || strings.EqualFold(dat2, dat1) {
|
|
continue
|
|
}
|
|
out, err := utils.GitDiff(dat1+"\n", dat2+"\n", &utils.GitDiffConfig{Color: false, Tool: "git"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(out) == 0 {
|
|
continue
|
|
}
|
|
diff.Updated[f2] = "```diff\n" + out + "\n```\n"
|
|
}
|
|
|
|
return diff, nil
|
|
}
|
|
|
|
// parseFiles diffs file paths for OTA and Directory input modes. IPSW mode
|
|
// is handled by filesJob via the volume-major orchestrator.
|
|
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
|
|
}
|
|
return fmt.Errorf("diff: parseFiles: IPSW mode uses filesJob via the volume-major orchestrator")
|
|
}
|
|
|
|
// openCacheStore initializes the persistent cache store for IPSW-mode runs
|
|
// and installs it on d via SetStore. Non-IPSW input modes are returned a nil
|
|
// cleanup so the per-orchestrator MemoryStore path stays in effect; failures
|
|
// to open the persistent store are logged and fall back to a temp-backed
|
|
// SQLiteStore via storage.OpenCacheStore.
|
|
func (d *Diff) openCacheStore() func() {
|
|
if d.conf == nil {
|
|
return nil
|
|
}
|
|
if d.Old.InputMode != inputModeIPSW || d.New.InputMode != inputModeIPSW {
|
|
return nil
|
|
}
|
|
opts := storage.CacheOptions{
|
|
OldInfo: d.Old.Info,
|
|
NewInfo: d.New.Info,
|
|
Dir: d.conf.Cache.Dir,
|
|
NoCache: d.conf.Cache.NoCache,
|
|
Clean: d.conf.Cache.Clean,
|
|
MaxBytes: d.conf.Cache.MaxBytes,
|
|
}
|
|
store, cleanup, err := storage.OpenCacheStore(opts)
|
|
if err != nil {
|
|
log.WithError(err).Warn("failed to open diff cache; falling back to in-memory store")
|
|
return nil
|
|
}
|
|
if d.conf.Cache.NoCache {
|
|
log.Debug("using temporary diff cache (--no-cache)")
|
|
} else if path, ok := persistentCachePath(d.Old.Info, d.New.Info, d.conf.Cache.Dir); ok {
|
|
log.Infof("using diff cache at %s", path)
|
|
} else {
|
|
log.Debug("using temporary diff cache (identity unavailable)")
|
|
}
|
|
d.SetStore(store)
|
|
return func() {
|
|
cleanup()
|
|
d.SetStore(nil)
|
|
}
|
|
}
|
|
|
|
// persistentCachePath returns the path OpenCacheStore would resolve for the
|
|
// (old, new) pair given the override directory, so callers can log the
|
|
// concrete path. ok=false when either Info lacks a BuildManifest and the
|
|
// cache must therefore fall back to a temp-backed store.
|
|
func persistentCachePath(oldInfo, newInfo *info.Info, dir string) (string, bool) {
|
|
oldID, err := storage.IPSWCacheIdentity(oldInfo)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
newID, err := storage.IPSWCacheIdentity(newInfo)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
path, err := storage.ResolveCachePath(oldID, newID, dir)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return path, true
|
|
}
|