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.
1069 lines
29 KiB
Go
1069 lines
29 KiB
Go
package diff
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/apex/log"
|
|
"github.com/blacktop/go-macho"
|
|
fwcmd "github.com/blacktop/ipsw/internal/commands/fw"
|
|
mcmd "github.com/blacktop/ipsw/internal/commands/macho"
|
|
"github.com/blacktop/ipsw/internal/magic"
|
|
"github.com/blacktop/ipsw/internal/search"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
"github.com/blacktop/ipsw/pkg/ftab"
|
|
"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/ota/types"
|
|
)
|
|
|
|
// reDmgPayload matches numeric DMG payload filenames found in
|
|
// IPSWs (e.g. "090-43228-337.dmg.aea"). These are never OTAs.
|
|
var reDmgPayload = regexp.MustCompile(
|
|
`[0-9]{3}-[0-9]{5}-[0-9]{3}\.dmg`,
|
|
)
|
|
|
|
var (
|
|
reOTAFirmwareIm4p = regexp.MustCompile(`\.im4p$`)
|
|
reOTAAgfxFirmware = regexp.MustCompile(`armfw_.*\.im4p$`)
|
|
reOTAExclaveBundleFW = regexp.MustCompile(`.*exclavecore_bundle.*\.im4p$`)
|
|
)
|
|
|
|
// tryOpenOTA attempts to open path as an OTA and validate it.
|
|
// Returns (handle, info, nil) on success.
|
|
// Returns (nil, nil, nil) when the file is not an OTA (silent miss).
|
|
// Returns (nil, nil, err) on fatal errors (I/O, decryption failures
|
|
// for files that look like OTAs based on magic bytes).
|
|
func tryOpenOTA(path string, conf *Config) (*otapkg.AA, *info.Info, error) {
|
|
base := filepath.Base(path)
|
|
// Pre-reject obvious DMG payload names and bare .dmg files.
|
|
if reDmgPayload.MatchString(base) {
|
|
return nil, nil, nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext == ".dmg" {
|
|
return nil, nil, nil
|
|
}
|
|
// Also reject .ipsw files — they are always IPSW archives.
|
|
if ext == ".ipsw" {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Check magic to decide error handling strategy.
|
|
// AEA/AA files are definitively OTA-like — propagate errors.
|
|
// ZIP files are ambiguous (could be IPSW) — downgrade.
|
|
likelyOTA, magicErr := isLikelyOTAByMagic(path)
|
|
if magicErr != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to probe %s: %w", path, magicErr,
|
|
)
|
|
}
|
|
|
|
o, err := otapkg.Open(path, resolveOTAConfig(path, conf))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) ||
|
|
errors.Is(err, os.ErrPermission) {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to open %s: %w", path, err,
|
|
)
|
|
}
|
|
if likelyOTA {
|
|
// File has AEA/AA magic — this is a real OTA error
|
|
// (e.g. decryption failure, missing key).
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to open OTA %s: %w "+
|
|
"(check --key-db / --key-val / --insecure)",
|
|
path, err,
|
|
)
|
|
}
|
|
// Ambiguous ZIP — not an OTA.
|
|
return nil, nil, nil
|
|
}
|
|
|
|
inf, err := o.Info()
|
|
if err != nil {
|
|
o.Close()
|
|
if likelyOTA {
|
|
// AEA/AA file opened but has no OTA metadata — odd
|
|
// but could be a corrupt OTA. Propagate.
|
|
return nil, nil, fmt.Errorf(
|
|
"opened %s as OTA archive but failed to "+
|
|
"parse metadata: %w",
|
|
path, err,
|
|
)
|
|
}
|
|
// ZIP that doesn't contain OTA plists — not an OTA.
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if inf.Plists == nil || inf.Plists.Type != "OTA" {
|
|
o.Close()
|
|
return nil, nil, nil
|
|
}
|
|
|
|
return o, inf, nil
|
|
}
|
|
|
|
// isLikelyOTAByMagic checks file magic to determine if the file
|
|
// is likely an OTA (AA or AEA format) rather than an ambiguous
|
|
// ZIP. Returns true for AA/AEA files where errors should be
|
|
// propagated, false for ZIPs where errors should be downgraded.
|
|
func isLikelyOTAByMagic(path string) (bool, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var buf [4]byte
|
|
if _, err := io.ReadFull(f, buf[:]); err != nil {
|
|
return false, fmt.Errorf("failed to read magic: %w", err)
|
|
}
|
|
switch magic.Magic(binary.LittleEndian.Uint32(buf[:])) {
|
|
case magic.MagicYAA1, magic.MagicAA01:
|
|
return true, nil
|
|
}
|
|
switch magic.Magic(binary.BigEndian.Uint32(buf[:])) {
|
|
case magic.MagicAEA1:
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// resolveOTAConfig builds an ota.Config from the diff Config's
|
|
// AEA fields, including key-database lookup.
|
|
func resolveOTAConfig(path string, conf *Config) *otapkg.Config {
|
|
oc := &otapkg.Config{
|
|
SymmetricKey: conf.AEAKeyVal,
|
|
Insecure: conf.AEAInsecure,
|
|
}
|
|
// Use key DB when no explicit key is provided.
|
|
if oc.SymmetricKey == "" && conf.AEAKeyDB != "" {
|
|
if dbKey, err := lookupAEAKey(
|
|
path, conf.AEAKeyDB,
|
|
); err == nil && dbKey != "" {
|
|
oc.SymmetricKey = dbKey
|
|
log.Debug("Using AEA key from database")
|
|
} else if err != nil {
|
|
log.WithError(err).Warn(
|
|
"Failed to lookup key in database",
|
|
)
|
|
}
|
|
}
|
|
return oc
|
|
}
|
|
|
|
// lookupAEAKey searches the JSON key database for a matching key.
|
|
// Mirrors cmd/ipsw/cmd/ota/ota.go:GetAEAKey() without importing
|
|
// the cmd package.
|
|
func lookupAEAKey(otaPath, keyDBPath string) (string, error) {
|
|
if keyDBPath == "" {
|
|
return "", nil
|
|
}
|
|
if otaPath == "" {
|
|
return "", fmt.Errorf("otaPath cannot be empty")
|
|
}
|
|
|
|
candidates, err := otaFilenameCandidatesForLookup(otaPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
data, err := os.ReadFile(keyDBPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to read AEA key database: %v", err,
|
|
)
|
|
}
|
|
|
|
var entries []types.AEAKeyEntry
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to parse AEA key database: %v", err,
|
|
)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
entryName := strings.TrimSuffix(
|
|
entry.Filename,
|
|
filepath.Ext(entry.Filename),
|
|
)
|
|
for _, candidate := range candidates {
|
|
if strings.EqualFold(entryName, candidate) {
|
|
return entry.Key, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func otaFilenameCandidatesForLookup(otaPath string) ([]string, error) {
|
|
otaFilename := filepath.Base(otaPath)
|
|
for {
|
|
ext := strings.ToLower(filepath.Ext(otaFilename))
|
|
if ext != ".aea" && ext != ".ota" && ext != ".zip" {
|
|
break
|
|
}
|
|
otaFilename = strings.TrimSuffix(
|
|
otaFilename, filepath.Ext(otaFilename),
|
|
)
|
|
}
|
|
if otaFilename == "" || otaFilename == "." {
|
|
return nil, fmt.Errorf("invalid OTA path: %s", otaPath)
|
|
}
|
|
|
|
candidates := []string{otaFilename}
|
|
if idx := strings.LastIndex(otaFilename, "_"); idx >= 0 && idx+1 < len(otaFilename) {
|
|
candidate := otaFilename[idx+1:]
|
|
if candidate != otaFilename {
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
}
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
// validateOTAScope rejects partial/delta and RSR OTAs that are
|
|
// not supported in Phase 1.
|
|
func validateOTAScope(inf *info.Info) error {
|
|
if inf.Plists == nil || inf.Plists.OTAInfo == nil {
|
|
return nil
|
|
}
|
|
props := &inf.Plists.OTAInfo.MobileAssetProperties
|
|
if props.PrerequisiteBuild != "" {
|
|
return fmt.Errorf(
|
|
"partial/delta OTA detected (prerequisite build: %s); "+
|
|
"`ipsw diff` requires full OTAs in this version — "+
|
|
"use a full OTA or pre-patched directory inputs "+
|
|
"via `ipsw ota patch`",
|
|
props.PrerequisiteBuild,
|
|
)
|
|
}
|
|
if props.SplatOnly {
|
|
return fmt.Errorf(
|
|
"RSR (Rapid Security Response) OTA detected; " +
|
|
"`ipsw diff` does not support RSR OTAs directly — " +
|
|
"use `ipsw ota patch rsr` to produce directory " +
|
|
"inputs, then diff those directories",
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unsupportedFlagsForOTAMode returns CLI flag names that are not
|
|
// fully supported when diffing OTA files. These flags will be
|
|
// skipped gracefully with a warning rather than hard-erroring.
|
|
func unsupportedFlagsForOTAMode(conf *Config) []string {
|
|
return nil
|
|
}
|
|
|
|
// isMacOSOTA checks whether the OTA targets macOS. Nil-safe for
|
|
// OTAs that lack a BuildManifest.
|
|
func isMacOSOTA(inf *info.Info) bool {
|
|
if inf.Plists.BuildManifest != nil {
|
|
return inf.IsMacOS()
|
|
}
|
|
if inf.Plists.OTAInfo != nil {
|
|
name := inf.Plists.OTAInfo.MobileAssetProperties.ProductSystemName
|
|
return strings.EqualFold(name, "macOS")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// configureOTAContext populates Context metadata from parsed OTA
|
|
// info. Handles both BuildManifest-present and absent cases.
|
|
func configureOTAContext(
|
|
ctx *Context, inf *info.Info, tmpDir string,
|
|
) {
|
|
ctx.Info = inf
|
|
ctx.InputMode = inputModeOTA
|
|
|
|
if inf.Plists.BuildManifest != nil {
|
|
ctx.Version = inf.Plists.BuildManifest.ProductVersion
|
|
ctx.Build = inf.Plists.BuildManifest.ProductBuildVersion
|
|
folder, err := inf.GetFolder()
|
|
if err != nil {
|
|
log.WithError(err).Warn(
|
|
"failed to get folder from OTA BuildManifest",
|
|
)
|
|
ctx.Folder = filepath.Join(
|
|
tmpDir, ctx.Build,
|
|
)
|
|
} else {
|
|
ctx.Folder = filepath.Join(tmpDir, folder)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Fallback: derive version/build from OTA-specific plists.
|
|
var product string
|
|
ctx.Version, ctx.Build, product = otaVersionBuildProduct(inf)
|
|
// Include product in folder name to avoid collisions when
|
|
// both sides share a build string.
|
|
folderName := ctx.Build
|
|
if product != "" {
|
|
folderName = ctx.Build + "__" + product
|
|
}
|
|
ctx.Folder = filepath.Join(tmpDir, folderName)
|
|
}
|
|
|
|
// ensureOTAPayloadFilesystem materializes the OTA payload-backed
|
|
// filesystem once per OTA side so multiple diff consumers can
|
|
// reuse it without re-extracting payload files.
|
|
func ensureOTAPayloadFilesystem(ctx *Context) error {
|
|
if ctx.payloadReady {
|
|
return nil
|
|
}
|
|
if ctx.otaFile == nil {
|
|
return fmt.Errorf("OTA handle not initialized")
|
|
}
|
|
|
|
payloadRoot := filepath.Join(ctx.Folder, "filesystem")
|
|
if err := os.MkdirAll(payloadRoot, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create OTA payload filesystem dir: %w", err)
|
|
}
|
|
|
|
utils.Indent(log.Info, 2)(
|
|
fmt.Sprintf("Extracting OTA payload filesystem to %s", payloadRoot),
|
|
)
|
|
if err := ctx.otaFile.GetPayloadFiles(".*", "", payloadRoot); err != nil {
|
|
return fmt.Errorf("failed to extract OTA payload filesystem: %w", err)
|
|
}
|
|
|
|
ctx.payloadRoot = payloadRoot
|
|
ctx.payloadReady = true
|
|
return nil
|
|
}
|
|
|
|
// otaDiffMounts returns the OTA sources that should behave like
|
|
// IPSW filesystem-backed content for diff consumers.
|
|
func otaDiffMounts(ctx *Context) map[string]mount {
|
|
mounts := make(map[string]mount, len(ctx.Mount)+1)
|
|
maps.Copy(mounts, ctx.Mount)
|
|
if ctx.payloadRoot != "" {
|
|
mounts["filesystem"] = mount{MountPath: ctx.payloadRoot}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func otaLaunchdSearchRoots(ctx *Context) []string {
|
|
var roots []string
|
|
if ctx.payloadRoot != "" {
|
|
roots = append(roots, ctx.payloadRoot)
|
|
}
|
|
for _, name := range sortedMountNames(ctx.Mount) {
|
|
roots = append(roots, ctx.Mount[name].MountPath)
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// otaVersionBuildProduct extracts version, build, and a product
|
|
// identifier from OTA metadata when BuildManifest is absent.
|
|
func otaVersionBuildProduct(
|
|
inf *info.Info,
|
|
) (version, build, product string) {
|
|
if inf.Plists.AssetDataInfo != nil {
|
|
version = inf.Plists.AssetDataInfo.ProductVersion
|
|
build = inf.Plists.AssetDataInfo.Build
|
|
product = inf.Plists.AssetDataInfo.ProductType
|
|
}
|
|
if inf.Plists.OTAInfo != nil {
|
|
props := &inf.Plists.OTAInfo.MobileAssetProperties
|
|
if version == "" {
|
|
version = props.OSVersion
|
|
}
|
|
if build == "" {
|
|
build = props.Build
|
|
}
|
|
if product == "" && len(props.SupportedDevices) > 0 {
|
|
product = props.SupportedDevices[0]
|
|
}
|
|
if product == "" {
|
|
product = props.ProductSystemName
|
|
}
|
|
}
|
|
if version == "" {
|
|
version = "unknown"
|
|
}
|
|
if build == "" {
|
|
build = "unknown"
|
|
}
|
|
return version, build, product
|
|
}
|
|
|
|
// mountOTACryptexes extracts and mounts the system (and, when
|
|
// present, app) cryptexes from the OTA. Uses ctx.Folder as the
|
|
// extraction directory so Old and New don't collide.
|
|
func mountOTACryptexes(ctx *Context) error {
|
|
outDir := ctx.Folder
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create cryptex output dir: %w", err)
|
|
}
|
|
|
|
systemDMG, err := extractOTASystemCryptex(ctx, outDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
systemMount := filepath.Join(
|
|
outDir, filepath.Base(systemDMG)+".mount",
|
|
)
|
|
mnt, err := mountCryptexDMG(systemDMG, systemMount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mount system cryptex: %w", err)
|
|
}
|
|
ctx.Mount["SystemOS"] = mnt
|
|
ctx.SystemOsDmgPath = mnt.DmgPath
|
|
ctx.MountPath = mnt.MountPath
|
|
ctx.IsMounted = mnt.IsMounted
|
|
|
|
// App cryptex is optional; mount it when present so file and
|
|
// MachO diffs see app-side content.
|
|
appDMG, err := ctx.otaFile.ExtractCryptex("app", outDir)
|
|
if err != nil {
|
|
if errors.Is(err, otapkg.ErrCryptexNotFound) {
|
|
utils.Indent(log.Debug, 2)("OTA has no app cryptex")
|
|
return nil
|
|
}
|
|
unmountOTACryptexes(ctx.Build, ctx)
|
|
return fmt.Errorf("failed to extract app cryptex: %w", err)
|
|
}
|
|
appMount := filepath.Join(
|
|
outDir, filepath.Base(appDMG)+".mount",
|
|
)
|
|
appMnt, err := mountCryptexDMG(appDMG, appMount)
|
|
if err != nil {
|
|
unmountOTACryptexes(ctx.Build, ctx)
|
|
return fmt.Errorf("failed to mount app cryptex: %w", err)
|
|
}
|
|
ctx.Mount["AppOS"] = appMnt
|
|
return nil
|
|
}
|
|
|
|
// extractOTASystemCryptex extracts the system cryptex DMG. For
|
|
// macOS OTAs containing both arm64e and x86_64h variants, the
|
|
// arm64e variant is selected to match parseDSC's arch filter.
|
|
func extractOTASystemCryptex(ctx *Context, tmpDir string) (string, error) {
|
|
if ctx.IsMacOS {
|
|
dmg, err := ctx.otaFile.ExtractCryptex("system-arm64e", tmpDir)
|
|
if err == nil {
|
|
return dmg, nil
|
|
}
|
|
if !errors.Is(err, otapkg.ErrCryptexNotFound) {
|
|
return "", fmt.Errorf("failed to extract arm64e system cryptex: %w", err)
|
|
}
|
|
// No arm64e variant — fall through to generic.
|
|
}
|
|
dmg, err := ctx.otaFile.ExtractCryptex("system", tmpDir)
|
|
if err == nil {
|
|
return dmg, nil
|
|
}
|
|
if errors.Is(err, otapkg.ErrCryptexNotFound) {
|
|
return "", fmt.Errorf(
|
|
"OTA does not contain a system cryptex; "+
|
|
"`ipsw diff` currently requires cryptex-based "+
|
|
"full OTAs — for older OTAs, extract DSC/kernel "+
|
|
"via `ipsw ota extract` and diff those directly: %w",
|
|
err,
|
|
)
|
|
}
|
|
return "", fmt.Errorf("failed to extract system cryptex: %w", err)
|
|
}
|
|
|
|
func mountCryptexDMG(dmgPath, mountPoint string) (mount, error) {
|
|
utils.Indent(log.Info, 2)(
|
|
fmt.Sprintf("Mounting cryptex %s", dmgPath),
|
|
)
|
|
attachedMountPoint, alreadyMounted, err := utils.MountDMG(dmgPath, mountPoint)
|
|
if err != nil {
|
|
return mount{}, err
|
|
}
|
|
if alreadyMounted {
|
|
utils.Indent(log.Info, 3)(
|
|
fmt.Sprintf("%s already mounted", dmgPath),
|
|
)
|
|
}
|
|
return mount{
|
|
DmgPath: dmgPath,
|
|
MountPath: attachedMountPoint,
|
|
IsMounted: alreadyMounted,
|
|
}, nil
|
|
}
|
|
|
|
// unmountOTACryptexes unmounts and cleans up cryptex mounts for
|
|
// an OTA diff.
|
|
func unmountOTACryptexes(label string, ctx *Context) {
|
|
for _, name := range sortedMountNames(ctx.Mount) {
|
|
mnt := ctx.Mount[name]
|
|
if mnt.IsMounted {
|
|
utils.Indent(log.Info, 2)(
|
|
fmt.Sprintf(
|
|
"Leaving '%s' %s cryptex mounted",
|
|
label, name,
|
|
),
|
|
)
|
|
continue
|
|
}
|
|
utils.Indent(log.Info, 2)(
|
|
fmt.Sprintf(
|
|
"Unmounting '%s' %s cryptex", label, name,
|
|
),
|
|
)
|
|
if err := utils.Retry(
|
|
3, 2*time.Second, func() error {
|
|
return utils.Unmount(mnt.MountPath, true)
|
|
},
|
|
); err != nil {
|
|
utils.Indent(log.Error, 3)(
|
|
fmt.Sprintf(
|
|
"failed to unmount '%s' %s cryptex: %v",
|
|
label, name, err,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// selectOTAKernelcachePair chooses which kernelcache members to
|
|
// extract from each OTA. Uses BuildManifest when present,
|
|
// otherwise falls back to shared-basename matching.
|
|
func selectOTAKernelcachePair(
|
|
oldCtx, newCtx *Context,
|
|
) (oldMember, newMember string, err error) {
|
|
oldHasManifest := oldCtx.Info != nil &&
|
|
oldCtx.Info.Plists.BuildManifest != nil
|
|
newHasManifest := newCtx.Info != nil &&
|
|
newCtx.Info.Plists.BuildManifest != nil
|
|
|
|
if oldHasManifest && newHasManifest {
|
|
return selectKernelcacheViaManifest(oldCtx, newCtx)
|
|
}
|
|
return selectKernelcacheViaBasename(oldCtx, newCtx)
|
|
}
|
|
|
|
// macOSKernelcacheDevice mirrors the fixed device selector used
|
|
// by IPSW mode for macOS kernelcache extraction.
|
|
const macOSKernelcacheDevice = "Macmini9,1"
|
|
|
|
func selectKernelcacheViaManifest(
|
|
oldCtx, newCtx *Context,
|
|
) (string, string, error) {
|
|
oldKCs := oldCtx.Info.Plists.GetKernelCaches()
|
|
newKCs := newCtx.Info.Plists.GetKernelCaches()
|
|
|
|
models := make([]string, 0, len(oldKCs))
|
|
for m := range oldKCs {
|
|
models = append(models, m)
|
|
}
|
|
sort.Strings(models)
|
|
// For macOS OTAs (which expose many device-specific
|
|
// kernelcaches), prefer the same fixed device the IPSW path
|
|
// uses so repeated runs and IPSW vs OTA diffs are consistent.
|
|
if oldCtx.IsMacOS {
|
|
if _, ok := oldKCs[macOSKernelcacheDevice]; ok {
|
|
rest := make([]string, 0, len(models))
|
|
for _, m := range models {
|
|
if m != macOSKernelcacheDevice {
|
|
rest = append(rest, m)
|
|
}
|
|
}
|
|
models = append([]string{macOSKernelcacheDevice}, rest...)
|
|
}
|
|
}
|
|
|
|
for _, kmodel := range models {
|
|
oldPaths := oldKCs[kmodel]
|
|
newPaths, ok := newKCs[kmodel]
|
|
if !ok || len(newPaths) == 0 {
|
|
continue
|
|
}
|
|
if len(oldPaths) == 0 {
|
|
continue
|
|
}
|
|
// Use the raw manifest path basename for OTA lookup.
|
|
// OTA files may be under prefixed paths like
|
|
// AssetData/boot/kernelcache.release.iPhone18,1.
|
|
oldName := filepath.Base(oldPaths[0])
|
|
newName := filepath.Base(newPaths[0])
|
|
|
|
// Find matching members in OTA file lists.
|
|
oldMember := findOTAMember(oldCtx.otaFile, oldName)
|
|
newMember := findOTAMember(newCtx.otaFile, newName)
|
|
if oldMember == "" {
|
|
return "", "", fmt.Errorf(
|
|
"kernelcache %s not found in old OTA", oldName,
|
|
)
|
|
}
|
|
if newMember == "" {
|
|
return "", "", fmt.Errorf(
|
|
"kernelcache %s not found in new OTA", newName,
|
|
)
|
|
}
|
|
log.WithField("device", kmodel).Debug(
|
|
"Selected kernelcache pair via BuildManifest",
|
|
)
|
|
return oldMember, newMember, nil
|
|
}
|
|
|
|
return "", "", fmt.Errorf(
|
|
"no matching device model found in BuildManifests; " +
|
|
"`ipsw diff` expects the same device type on both sides",
|
|
)
|
|
}
|
|
|
|
func selectKernelcacheViaBasename(
|
|
oldCtx, newCtx *Context,
|
|
) (string, string, error) {
|
|
log.Warn(
|
|
"BuildManifest missing from one or both OTAs; " +
|
|
"falling back to basename matching for kernelcache",
|
|
)
|
|
reKC := regexp.MustCompile(`kernelcache\.`)
|
|
oldKCs := collectOTAMembers(oldCtx.otaFile, reKC)
|
|
newKCs := collectOTAMembers(newCtx.otaFile, reKC)
|
|
|
|
// Build maps keyed by basename.
|
|
oldByBase := make(map[string]string, len(oldKCs))
|
|
for _, m := range oldKCs {
|
|
oldByBase[filepath.Base(m)] = m
|
|
}
|
|
newByBase := make(map[string]string, len(newKCs))
|
|
for _, m := range newKCs {
|
|
newByBase[filepath.Base(m)] = m
|
|
}
|
|
|
|
// Find shared basenames, pick deterministically.
|
|
var shared []string
|
|
for base := range oldByBase {
|
|
if _, ok := newByBase[base]; ok {
|
|
shared = append(shared, base)
|
|
}
|
|
}
|
|
sort.Strings(shared)
|
|
|
|
if len(shared) == 0 {
|
|
return "", "", fmt.Errorf(
|
|
"no shared kernelcache basename found between OTAs "+
|
|
"(old: %v, new: %v)",
|
|
basenames(oldKCs), basenames(newKCs),
|
|
)
|
|
}
|
|
|
|
pick := shared[0]
|
|
log.WithField("kernelcache", pick).Debug(
|
|
"Selected kernelcache pair via basename fallback",
|
|
)
|
|
return oldByBase[pick], newByBase[pick], nil
|
|
}
|
|
|
|
// findOTAMember finds an OTA file matching name. Tries exact
|
|
// basename match first, then suffix match to handle OTA paths
|
|
// like AssetData/boot/<name>.
|
|
func findOTAMember(o *otapkg.AA, name string) string {
|
|
// First pass: exact basename.
|
|
for _, f := range o.Files() {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
if filepath.Base(f.Name()) == name {
|
|
return f.Name()
|
|
}
|
|
}
|
|
// Second pass: suffix match (handles path-prefixed entries).
|
|
suffix := "/" + name
|
|
for _, f := range o.Files() {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(f.Name(), suffix) {
|
|
return f.Name()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// collectOTAMembers returns all non-dir OTA file names matching re.
|
|
func collectOTAMembers(o *otapkg.AA, re *regexp.Regexp) []string {
|
|
var out []string
|
|
for _, f := range o.Files() {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
if re.MatchString(f.Name()) {
|
|
out = append(out, f.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func basenames(paths []string) []string {
|
|
out := make([]string, len(paths))
|
|
for i, p := range paths {
|
|
out[i] = filepath.Base(p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractOTAKernelcache extracts a specific kernelcache member
|
|
// from the OTA, decompresses it, and sets ctx.Kernel.Path.
|
|
func extractOTAKernelcache(
|
|
ctx *Context, memberName, outputDir string,
|
|
) error {
|
|
f, err := ctx.otaFile.Open(memberName, false)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to open kernelcache %s in OTA: %w",
|
|
memberName, err,
|
|
)
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to read kernelcache %s: %w",
|
|
memberName, err,
|
|
)
|
|
}
|
|
|
|
comp, err := kernelcache.ParseImg4Data(data)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to parse kernelcache IMG4: %w", err,
|
|
)
|
|
}
|
|
|
|
kdata, err := kernelcache.DecompressData(comp)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to decompress kernelcache: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to create output dir: %w", err,
|
|
)
|
|
}
|
|
|
|
outPath := filepath.Join(
|
|
outputDir, filepath.Base(memberName),
|
|
)
|
|
if err := os.WriteFile(outPath, kdata, 0o644); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to write kernelcache: %w", err,
|
|
)
|
|
}
|
|
|
|
ctx.Kernel.Path = outPath
|
|
return nil
|
|
}
|
|
|
|
// parseOTAIBoot extracts and parses the primary iBoot payload
|
|
// from a direct OTA. Mirrors the IPSW path by preferring
|
|
// iBoot.*.im4p members.
|
|
func parseOTAIBoot(ctx *Context) (*iboot.IBoot, error) {
|
|
var candidates []string
|
|
reIboot := regexp.MustCompile(`iBoot\..*\.im4p$`)
|
|
|
|
for _, f := range ctx.otaFile.Files() {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
if reIboot.MatchString(f.Name()) {
|
|
candidates = append(candidates, f.Name())
|
|
}
|
|
}
|
|
sort.Strings(candidates)
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("failed to find iBoot im4p in OTA")
|
|
}
|
|
|
|
rc, err := ctx.otaFile.Open(candidates[0], false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open iBoot im4p %s: %w", candidates[0], err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read iBoot im4p %s: %w", candidates[0], err)
|
|
}
|
|
|
|
im4p, err := img4.ParsePayload(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse iBoot im4p %s: %w", candidates[0], err)
|
|
}
|
|
payload, err := im4p.GetData()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode iBoot im4p %s: %w", candidates[0], err)
|
|
}
|
|
|
|
return iboot.Parse(payload)
|
|
}
|
|
|
|
// diffFirmwaresFromOTA extracts im4p firmware files from both
|
|
// OTA handles and generates a firmware MachO diff.
|
|
func diffFirmwaresFromOTA(
|
|
oldCtx, newCtx *Context, conf *mcmd.DiffConfig,
|
|
) (*mcmd.MachoDiff, error) {
|
|
diff := &mcmd.MachoDiff{
|
|
Updated: make(map[string]string),
|
|
}
|
|
|
|
prev := make(map[string]*mcmd.DiffInfo)
|
|
if err := forEachOTAFirmware(oldCtx.otaFile, func(
|
|
name string, m *macho.File,
|
|
) error {
|
|
prev[name] = mcmd.GenerateDiffInfo(m, conf)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"failed to parse firmwares in 'Old' OTA: %w", err,
|
|
)
|
|
}
|
|
|
|
next := make(map[string]*mcmd.DiffInfo)
|
|
if err := forEachOTAFirmware(newCtx.otaFile, func(
|
|
name string, m *macho.File,
|
|
) error {
|
|
next[name] = mcmd.GenerateDiffInfo(m, conf)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"failed to parse firmwares in 'New' OTA: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := diff.Generate(prev, next, conf); err != nil {
|
|
return nil, err
|
|
}
|
|
return diff, nil
|
|
}
|
|
|
|
// forEachOTAFirmware iterates im4p firmware files in an OTA,
|
|
// opens each as a MachO, and calls handler.
|
|
func forEachOTAFirmware(
|
|
o *otapkg.AA,
|
|
handler func(string, *macho.File) error,
|
|
) error {
|
|
for _, f := range o.Files() {
|
|
if f.IsDir() || !reOTAFirmwareIm4p.MatchString(f.Name()) {
|
|
continue
|
|
}
|
|
rc, err := o.Open(f.Name(), false)
|
|
if err != nil {
|
|
log.WithError(err).Debugf(
|
|
"failed to open firmware %s", f.Name(),
|
|
)
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
log.WithError(err).Debugf(
|
|
"failed to read firmware %s", f.Name(),
|
|
)
|
|
continue
|
|
}
|
|
im4p, err := img4.ParsePayload(data)
|
|
if err != nil {
|
|
log.WithError(err).Debugf(
|
|
"failed to parse im4p %s", f.Name(),
|
|
)
|
|
continue
|
|
}
|
|
payload, err := im4p.GetData()
|
|
if err != nil {
|
|
log.WithError(err).Debugf(
|
|
"failed to get im4p data %s", f.Name(),
|
|
)
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case reOTAAgfxFirmware.MatchString(f.Name()):
|
|
ft, err := ftab.Parse(bytes.NewReader(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse ftab: %w", err)
|
|
}
|
|
for _, entry := range ft.Entries {
|
|
entryData, err := io.ReadAll(entry)
|
|
if err != nil {
|
|
ft.Close()
|
|
return fmt.Errorf("failed to read ftab entry: %w", err)
|
|
}
|
|
m, err := macho.NewFile(bytes.NewReader(entryData))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name := search.FirmwareMemberKey(f.Name(), "agx_"+filepath.Base(string(entry.Tag[:])))
|
|
if err := handler(name, m); err != nil {
|
|
m.Close()
|
|
ft.Close()
|
|
return fmt.Errorf("failed to handle firmware %s: %w", name, err)
|
|
}
|
|
m.Close()
|
|
}
|
|
ft.Close()
|
|
|
|
case reOTAExclaveBundleFW.MatchString(f.Name()):
|
|
tmpDir, err := os.MkdirTemp("", "ipsw_ota_exclave_fw")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp directory for exclave cores: %w", err)
|
|
}
|
|
outDir := filepath.Join(tmpDir, "exclave", filepath.FromSlash(f.Name()))
|
|
out, err := fwcmd.ExtractExclaveCores(payload, outDir)
|
|
if err != nil {
|
|
_ = os.RemoveAll(tmpDir)
|
|
return fmt.Errorf("failed to split exclave apps FW: %w", err)
|
|
}
|
|
for _, path := range out {
|
|
m, err := macho.Open(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
name := search.FirmwareMemberKey(f.Name(), "exclave_"+filepath.Base(path))
|
|
if err := handler(name, m); err != nil {
|
|
m.Close()
|
|
_ = os.RemoveAll(tmpDir)
|
|
return fmt.Errorf("failed to handle firmware %s: %w", name, err)
|
|
}
|
|
m.Close()
|
|
}
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
log.WithError(err).Debug("failed to remove temporary exclave firmware directory")
|
|
}
|
|
|
|
default:
|
|
m, err := macho.NewFile(bytes.NewReader(payload))
|
|
if err != nil {
|
|
// Not a MachO (e.g. raw firmware blob) — skip.
|
|
continue
|
|
}
|
|
name := search.FirmwareMemberKey(f.Name(), "")
|
|
if err := handler(name, m); err != nil {
|
|
m.Close()
|
|
return fmt.Errorf(
|
|
"failed to handle firmware %s: %w",
|
|
name, err,
|
|
)
|
|
}
|
|
m.Close()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// collectFeatureFlagsFromMounts walks mounted volumes for
|
|
// /System/Library/FeatureFlags plists and collects their content.
|
|
// collectFeatureFlagsFromMount walks System/Library/FeatureFlags under a
|
|
// single mount root, populating out with mount-relative plist content.
|
|
// The volume label is only used in error messages for context.
|
|
func collectFeatureFlagsFromMount(mountPath, volumeLabel string, out map[string]string) error {
|
|
ffDir := filepath.Join(mountPath, "System", "Library", "FeatureFlags")
|
|
if _, err := os.Stat(ffDir); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to stat FeatureFlags in %s: %w", volumeLabel, err)
|
|
}
|
|
if err := search.WalkFilesInRootFrom(mountPath, ffDir, func(path string) error {
|
|
if !strings.HasSuffix(path, ".plist") {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read plist %s: %w", path, err)
|
|
}
|
|
rel, err := filepath.Rel(ffDir, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
out[rel] = string(data)
|
|
return nil
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to walk FeatureFlags in %s: %w", volumeLabel, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// launchdConfigFromRoots finds and parses launchd from one of the
|
|
// provided filesystem roots, preferring /sbin/launchd when present.
|
|
func launchdConfigFromRoots(roots []string) (string, error) {
|
|
var candidates []string
|
|
for _, root := range roots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(root); err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf("failed to stat launchd search root %s: %w", root, err)
|
|
}
|
|
if err := walkFolderFiles(root, func(_ string, path string) error {
|
|
if filepath.Base(path) == "launchd" {
|
|
candidates = append(candidates, path)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return "", fmt.Errorf("failed to walk launchd search root %s: %w", root, err)
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return "", fmt.Errorf("launchd not found under any OTA source root")
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
iPreferred := strings.HasSuffix(candidates[i], filepath.Join("sbin", "launchd"))
|
|
jPreferred := strings.HasSuffix(candidates[j], filepath.Join("sbin", "launchd"))
|
|
if iPreferred != jPreferred {
|
|
return iPreferred
|
|
}
|
|
return candidates[i] < candidates[j]
|
|
})
|
|
|
|
launchdPath := candidates[0]
|
|
m, err := macho.Open(launchdPath)
|
|
if err != nil {
|
|
fat, ferr := macho.OpenFat(launchdPath)
|
|
if ferr != nil {
|
|
return "", fmt.Errorf("failed to open launchd: %w", err)
|
|
}
|
|
defer fat.Close()
|
|
m = fat.Arches[len(fat.Arches)-1].File
|
|
} else {
|
|
defer m.Close()
|
|
}
|
|
|
|
sec := m.Section("__TEXT", "__config")
|
|
if sec == nil {
|
|
return "", fmt.Errorf("launchd at %s has no __TEXT,__config section", launchdPath)
|
|
}
|
|
data, err := sec.Data()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read launchd config: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|