Files
ipsw/internal/diff/diff.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

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
}