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.
967 lines
27 KiB
Go
967 lines
27 KiB
Go
package macho
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/gob"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/blacktop/go-macho"
|
|
"github.com/blacktop/go-macho/types"
|
|
"github.com/blacktop/ipsw/internal/search"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
"github.com/blacktop/ipsw/pkg/signature"
|
|
)
|
|
|
|
// hashStreamPool reuses sha256 hashers and copy buffers across the many
|
|
// per-section and per-function hashes a single GenerateDiffInfo computes, so
|
|
// section/function content is hashed incrementally instead of slurped whole
|
|
// into a []byte (Section.Data / GetFunctionData were the dominant cold-path
|
|
// allocations: ~71% of alloc-space and most of the alloc-count).
|
|
var hashStreamPool = sync.Pool{New: func() any {
|
|
return &hashStream{h: sha256.New(), buf: make([]byte, 32*1024)}
|
|
}}
|
|
|
|
type hashStream struct {
|
|
h hash.Hash
|
|
buf []byte
|
|
}
|
|
|
|
// streamSHA256 hashes r incrementally and returns the hex digest. ok is false
|
|
// when r yields no bytes or errors before any data, matching the previous
|
|
// "empty data -> no hash" behavior of the slurp-based hashers.
|
|
func streamSHA256(r io.Reader) (string, bool) {
|
|
hs := hashStreamPool.Get().(*hashStream)
|
|
defer hashStreamPool.Put(hs)
|
|
hs.h.Reset()
|
|
n, err := io.CopyBuffer(hs.h, r, hs.buf)
|
|
if err != nil || n == 0 {
|
|
return "", false
|
|
}
|
|
var sum [sha256.Size]byte
|
|
return hex.EncodeToString(hs.h.Sum(sum[:0])), true
|
|
}
|
|
|
|
var xbsTemporaryBuildPathRE = regexp.MustCompile(`^/Library/Caches/com\.apple\.xbs/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/TemporaryDirectory\.[^/\s]+`)
|
|
|
|
const xbsTemporaryBuildPathPlaceholder = "/Library/Caches/com.apple.xbs/<UUID>/TemporaryDirectory.<TMP>"
|
|
|
|
func normalizeCStringForDiff(value string) string {
|
|
return xbsTemporaryBuildPathRE.ReplaceAllString(value, xbsTemporaryBuildPathPlaceholder)
|
|
}
|
|
|
|
func normalizeCStringsForDiff(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
normalized := make([]string, len(values))
|
|
for idx, value := range values {
|
|
normalized[idx] = normalizeCStringForDiff(value)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func diffNormalizedCStrings(oldValues, newValues []string) ([]string, []string) {
|
|
normalizedOldValues := normalizeCStringsForDiff(oldValues)
|
|
normalizedNewValues := normalizeCStringsForDiff(newValues)
|
|
|
|
added := utils.Difference(normalizedNewValues, normalizedOldValues)
|
|
sort.Strings(added)
|
|
removed := utils.Difference(normalizedOldValues, normalizedNewValues)
|
|
sort.Strings(removed)
|
|
|
|
return added, removed
|
|
}
|
|
|
|
type cachedDiffInfo struct {
|
|
Info *DiffInfo
|
|
}
|
|
|
|
func cacheFileForKey(cacheDir, key string) string {
|
|
sum := sha256.Sum256([]byte(key))
|
|
return filepath.Join(cacheDir, hex.EncodeToString(sum[:])+".gob")
|
|
}
|
|
|
|
// WriteCachedDiffInfo serializes a single DiffInfo to disk under cacheDir,
|
|
// keyed by the binary's mount-relative path. Used by the LowMemory paths.
|
|
func WriteCachedDiffInfo(cacheDir, key string, info *DiffInfo) error {
|
|
path := cacheFileForKey(cacheDir, key)
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return gob.NewEncoder(f).Encode(&cachedDiffInfo{Info: info})
|
|
}
|
|
|
|
// ReadCachedDiffInfo deserializes a previously cached DiffInfo. Used by
|
|
// the LowMemory paths.
|
|
func ReadCachedDiffInfo(cacheDir, key string) (*DiffInfo, error) {
|
|
path := cacheFileForKey(cacheDir, key)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
var c cachedDiffInfo
|
|
if err := gob.NewDecoder(f).Decode(&c); err != nil {
|
|
return nil, err
|
|
}
|
|
if c.Info == nil {
|
|
return nil, fmt.Errorf("cached diff info missing for %s", key)
|
|
}
|
|
return c.Info, nil
|
|
}
|
|
|
|
// FormatUpdatedDiff formats a single-file diff in the same style as MachoDiff.Generate.
|
|
// Returns an empty string if no printable diff content is produced.
|
|
func FormatUpdatedDiff(oldInfo, newInfo *DiffInfo, conf *DiffConfig) (string, error) {
|
|
if oldInfo == nil || newInfo == nil {
|
|
return "", fmt.Errorf("nil diff info")
|
|
}
|
|
|
|
out, err := utils.GitDiff(oldInfo.String()+"\n", newInfo.String()+"\n", &utils.GitDiffConfig{Color: conf.Color, Tool: conf.DiffTool})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(out) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var b strings.Builder
|
|
if conf.Markdown {
|
|
b.WriteString("```diff\n")
|
|
b.WriteString(out)
|
|
} else {
|
|
b.WriteString(out)
|
|
}
|
|
|
|
// Symbols
|
|
newSyms := utils.Difference(newInfo.Symbols, oldInfo.Symbols)
|
|
sort.Strings(newSyms)
|
|
rmSyms := utils.Difference(oldInfo.Symbols, newInfo.Symbols)
|
|
sort.Strings(rmSyms)
|
|
if len(newSyms) > 0 || len(rmSyms) > 0 {
|
|
b.WriteString("Symbols:\n")
|
|
for _, s := range newSyms {
|
|
b.WriteString(fmt.Sprintf("+ %s\n", s))
|
|
}
|
|
for _, s := range rmSyms {
|
|
b.WriteString(fmt.Sprintf("- %s\n", s))
|
|
}
|
|
}
|
|
|
|
// Functions
|
|
if conf.FuncStarts {
|
|
printable := func(f types.Function, smap map[uint64]string) string {
|
|
sym, ok := smap[f.StartAddr]
|
|
if ok {
|
|
return sym
|
|
}
|
|
return fmt.Sprintf("sub_%x", f.StartAddr)
|
|
}
|
|
|
|
funcs1 := oldInfo.Starts
|
|
funcs2 := newInfo.Starts
|
|
n1, n2 := len(funcs1), len(funcs2)
|
|
|
|
var fb strings.Builder
|
|
appendLine := func(s string) {
|
|
if fb.Len() == 0 {
|
|
fb.WriteString("Functions:\n")
|
|
}
|
|
fb.WriteString(s)
|
|
}
|
|
|
|
if n1 == n2 {
|
|
consecutiveMismatch := 0
|
|
const maxMismatch = 5
|
|
|
|
for i := range n1 {
|
|
f1 := funcs1[i]
|
|
f2 := funcs2[i]
|
|
f1.Name = printable(f1, oldInfo.SymbolMap)
|
|
f2.Name = printable(f2, newInfo.SymbolMap)
|
|
|
|
if f1.Name != "" && f1.Name == f2.Name {
|
|
sz1 := f1.EndAddr - f1.StartAddr
|
|
sz2 := f2.EndAddr - f2.StartAddr
|
|
if sz1 != sz2 {
|
|
appendLine(fmt.Sprintf("~ %s : %d -> %d\n", f1.Name, sz1, sz2))
|
|
} else {
|
|
appendFunctionHashChange(appendLine, oldInfo, newInfo, f1, f2)
|
|
}
|
|
consecutiveMismatch = 0
|
|
continue
|
|
}
|
|
|
|
sz1 := f1.EndAddr - f1.StartAddr
|
|
sz2 := f2.EndAddr - f2.StartAddr
|
|
|
|
if sz1 == sz2 {
|
|
appendFunctionHashChange(appendLine, oldInfo, newInfo, f1, f2)
|
|
consecutiveMismatch = 0
|
|
continue
|
|
}
|
|
|
|
appendLine(fmt.Sprintf("~ %s -> %s : %d -> %d\n", f1.Name, f2.Name, sz1, sz2))
|
|
consecutiveMismatch++
|
|
|
|
if consecutiveMismatch >= maxMismatch {
|
|
recovered := false
|
|
const seekAhead = 6
|
|
if i+seekAhead < n1 {
|
|
matches := 0
|
|
for k := 1; k <= seekAhead && i+k < n1; k++ {
|
|
if (funcs1[i+k].EndAddr - funcs1[i+k].StartAddr) == (funcs2[i+k].EndAddr - funcs2[i+k].StartAddr) {
|
|
matches++
|
|
if matches >= 3 {
|
|
recovered = true
|
|
break
|
|
}
|
|
} else {
|
|
matches = 0
|
|
}
|
|
}
|
|
}
|
|
if !recovered {
|
|
fb.Reset()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if fb.Len() > 0 {
|
|
b.WriteString(fb.String())
|
|
}
|
|
} else {
|
|
i, j := 0, 0
|
|
consecutiveNoise := 0
|
|
const noiseLimit = 6
|
|
|
|
for i < n1 && j < n2 {
|
|
f1 := funcs1[i]
|
|
f2 := funcs2[j]
|
|
f1.Name = printable(f1, oldInfo.SymbolMap)
|
|
f2.Name = printable(f2, newInfo.SymbolMap)
|
|
if f1.Name != "" && f1.Name == f2.Name {
|
|
sz1 := f1.EndAddr - f1.StartAddr
|
|
sz2 := f2.EndAddr - f2.StartAddr
|
|
if sz1 != sz2 {
|
|
appendLine(fmt.Sprintf("~ %s : %d -> %d\n", f1.Name, sz1, sz2))
|
|
} else {
|
|
appendFunctionHashChange(appendLine, oldInfo, newInfo, f1, f2)
|
|
}
|
|
i++
|
|
j++
|
|
consecutiveNoise = 0
|
|
continue
|
|
}
|
|
|
|
if (f1.EndAddr - f1.StartAddr) == (f2.EndAddr - f2.StartAddr) {
|
|
appendFunctionHashChange(appendLine, oldInfo, newInfo, f1, f2)
|
|
i++
|
|
j++
|
|
consecutiveNoise = 0
|
|
continue
|
|
}
|
|
|
|
if j+1 < n2 && (f1.EndAddr-f1.StartAddr) == (funcs2[j+1].EndAddr-funcs2[j+1].StartAddr) {
|
|
appendLine(fmt.Sprintf("+ %s\n", f2.Name))
|
|
j++
|
|
consecutiveNoise++
|
|
} else if i+1 < n1 && (funcs1[i+1].EndAddr-funcs1[i+1].StartAddr) == (f2.EndAddr-f2.StartAddr) {
|
|
appendLine(fmt.Sprintf("- %s\n", f1.Name))
|
|
i++
|
|
consecutiveNoise++
|
|
} else {
|
|
consecutiveNoise++
|
|
}
|
|
|
|
if consecutiveNoise >= noiseLimit {
|
|
fb.Reset()
|
|
break
|
|
}
|
|
}
|
|
|
|
if fb.Len() > 0 {
|
|
b.WriteString(fb.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// CStrings
|
|
if conf.CStrings {
|
|
newStrs, rmStrs := diffNormalizedCStrings(oldInfo.CStrings, newInfo.CStrings)
|
|
if len(newStrs) > 0 || len(rmStrs) > 0 {
|
|
b.WriteString("CStrings:\n")
|
|
for _, s := range newStrs {
|
|
b.WriteString(fmt.Sprintf("+ %#v\n", s))
|
|
}
|
|
for _, s := range rmStrs {
|
|
b.WriteString(fmt.Sprintf("- %#v\n", s))
|
|
}
|
|
}
|
|
}
|
|
|
|
if conf.Markdown {
|
|
b.WriteString("\n```\n")
|
|
}
|
|
|
|
return b.String(), nil
|
|
}
|
|
|
|
type DiffConfig struct {
|
|
Markdown bool
|
|
Color bool
|
|
DiffTool string
|
|
AllowList []string
|
|
BlockList []string
|
|
CStrings bool
|
|
FuncStarts bool
|
|
IgnoreLoadCommands bool
|
|
PemDB string
|
|
SymMap map[string]signature.SymbolMap
|
|
Verbose bool
|
|
}
|
|
|
|
type MachoDiff struct {
|
|
New []string `json:"new,omitempty"`
|
|
Removed []string `json:"removed,omitempty"`
|
|
Updated map[string]string `json:"updated,omitempty"`
|
|
}
|
|
|
|
type section struct {
|
|
Name string `json:"name,omitempty"`
|
|
Size uint64 `json:"size,omitempty"`
|
|
Hash string `json:"hash,omitempty"`
|
|
}
|
|
|
|
type DiffInfo struct {
|
|
Version string
|
|
UUID string
|
|
LoadCmdHash string // internal structural digest; not rendered in reports
|
|
Imports []string
|
|
Sections []section
|
|
Functions int
|
|
Starts []types.Function
|
|
FunctionHashes map[uint64]string
|
|
Symbols []string
|
|
CStrings []string
|
|
SymbolMap map[uint64]string
|
|
Verbose bool
|
|
}
|
|
|
|
func GenerateDiffInfo(m *macho.File, conf *DiffConfig, smaps ...signature.SymbolMap) *DiffInfo {
|
|
var secs []section
|
|
for _, s := range m.Sections {
|
|
name := s.Seg + "." + s.Name
|
|
if !sectionIncluded(name, conf) {
|
|
continue
|
|
}
|
|
sec := section{
|
|
Name: name,
|
|
Size: s.Size,
|
|
}
|
|
if hash, ok := sectionContentHash(s); ok {
|
|
sec.Hash = hash
|
|
}
|
|
secs = append(secs, sec)
|
|
}
|
|
var starts []types.Function
|
|
if fns := m.GetFunctions(); fns != nil {
|
|
starts = fns
|
|
}
|
|
functionHashes := functionContentHashes(m, starts, conf)
|
|
var sourceVersion string
|
|
if m.SourceVersion() != nil {
|
|
sourceVersion = m.SourceVersion().Version.String()
|
|
}
|
|
var uuidStr string
|
|
if m.UUID() != nil {
|
|
uuidStr = m.UUID().String()
|
|
}
|
|
smap := make(map[uint64]string)
|
|
if len(smaps) > 0 {
|
|
maps.Copy(smap, smaps[0])
|
|
}
|
|
var syms []string
|
|
if m.Symtab != nil {
|
|
for _, sym := range m.Symtab.Syms {
|
|
syms = append(syms, sym.Name)
|
|
if conf.FuncStarts {
|
|
if len(sym.Name) != 0 && sym.Name != "<redacted>" {
|
|
smap[sym.Value] = sym.Name
|
|
}
|
|
}
|
|
}
|
|
slices.Sort(syms)
|
|
}
|
|
var strs []string
|
|
if conf.CStrings {
|
|
if cs, err := m.GetCStrings(); err == nil {
|
|
for _, val := range cs {
|
|
str2addr := slices.Collect(maps.Keys(val))
|
|
strs = append(strs, str2addr...)
|
|
}
|
|
slices.Sort(strs)
|
|
}
|
|
if cfstrs, err := m.GetCFStrings(); err == nil {
|
|
for _, val := range cfstrs {
|
|
strs = append(strs, val.Name)
|
|
}
|
|
slices.Sort(strs)
|
|
}
|
|
}
|
|
var loadCmdHash string
|
|
if !conf.IgnoreLoadCommands {
|
|
loadCmdHash, _ = loadCommandsHash(m)
|
|
}
|
|
return &DiffInfo{
|
|
Version: sourceVersion,
|
|
UUID: uuidStr,
|
|
LoadCmdHash: loadCmdHash,
|
|
Imports: m.ImportedLibraries(),
|
|
Sections: secs,
|
|
Functions: len(starts),
|
|
Starts: starts,
|
|
FunctionHashes: functionHashes,
|
|
Symbols: syms,
|
|
CStrings: strs,
|
|
SymbolMap: smap,
|
|
Verbose: conf.Verbose,
|
|
}
|
|
}
|
|
|
|
// loadCommandsHash returns sha256(header || load_commands) with volatile
|
|
// build-metadata and linkedit-position fields zeroed. Structural load-command
|
|
// changes — dependency names, rpaths, segment layout, command additions/removals
|
|
// — still flip the hash, while point-release metadata churn does not.
|
|
//
|
|
// Returns ("", err) on read failure; callers should treat an empty hash as
|
|
// "not available" and skip the LoadCmdHash leg of the comparison.
|
|
func loadCommandsHash(m *macho.File) (string, error) {
|
|
if m == nil {
|
|
return "", nil
|
|
}
|
|
hdrSize := 28
|
|
if m.Magic == types.Magic64 {
|
|
hdrSize = 32
|
|
}
|
|
region := hdrSize + int(m.SizeCommands)
|
|
if region <= hdrSize {
|
|
return "", nil
|
|
}
|
|
buf := make([]byte, region)
|
|
n, err := m.ReadAt(buf, 0)
|
|
if err != nil || n != region {
|
|
return "", err
|
|
}
|
|
return loadCommandsDigest(buf, hdrSize, m.Loads), nil
|
|
}
|
|
|
|
// loadCommandsDigest hashes the header + load-command region with each
|
|
// command's VOLATILE bytes zeroed, so the digest flips only on STRUCTURAL
|
|
// load-command changes (a dependency added/removed/renamed, an rpath change,
|
|
// segment layout) and not on the per-release build-metadata churn that
|
|
// rebuilds every binary in a point release (SDK/min-OS/source/dylib versions,
|
|
// re-signed code-signature size, shifted linkedit offsets). This mirrors the
|
|
// kernelcache diff's "functional segments unchanged; only build metadata
|
|
// differs -> skip" stance so the two paths agree on what counts as a change.
|
|
func loadCommandsDigest(buf []byte, hdrSize int, loads []macho.Load) string {
|
|
off := hdrSize
|
|
for _, l := range loads {
|
|
sz := int(l.LoadSize())
|
|
if sz <= 0 || off+sz > len(buf) {
|
|
break
|
|
}
|
|
for _, r := range volatileLoadCmdRanges(l.Command(), sz) {
|
|
start, end := off+r[0], off+r[1]
|
|
if start < off+8 { // never touch the cmd/cmdsize header
|
|
start = off + 8
|
|
}
|
|
if end > off+sz {
|
|
end = off + sz
|
|
}
|
|
for i := start; i < end; i++ {
|
|
buf[i] = 0
|
|
}
|
|
}
|
|
off += sz
|
|
}
|
|
sum := sha256.Sum256(buf)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// volatileLoadCmdRanges returns the byte ranges (relative to the start of a
|
|
// load command of the given type and size) whose contents are build metadata
|
|
// or linkedit position rather than structural identity, and so are zeroed
|
|
// before hashing. Offsets follow the Mach-O load_command layouts; the 8-byte
|
|
// cmd/cmdsize header is never included. Anything not listed here (segments,
|
|
// LC_RPATH, LC_MAIN, encryption info, ...) is hashed verbatim.
|
|
func volatileLoadCmdRanges(cmd types.LoadCmd, sz int) [][2]int {
|
|
switch cmd {
|
|
case types.LC_UUID:
|
|
return [][2]int{{8, 24}} // the 16-byte UUID
|
|
case types.LC_SOURCE_VERSION:
|
|
return [][2]int{{8, 16}} // version uint64
|
|
case types.LC_BUILD_VERSION:
|
|
return [][2]int{{12, sz}} // keep platform; drop minos/sdk/ntools/tools
|
|
case types.LC_VERSION_MIN_MACOSX, types.LC_VERSION_MIN_IPHONEOS,
|
|
types.LC_VERSION_MIN_TVOS, types.LC_VERSION_MIN_WATCHOS:
|
|
return [][2]int{{8, 16}} // version + sdk
|
|
case types.LC_LOAD_DYLIB, types.LC_ID_DYLIB, types.LC_LOAD_WEAK_DYLIB,
|
|
types.LC_REEXPORT_DYLIB, types.LC_LAZY_LOAD_DYLIB, types.LC_LOAD_UPWARD_DYLIB:
|
|
// Keep name_offset[8:12] and the name string[24:]; drop timestamp +
|
|
// current_version + compatibility_version so a dependency's version
|
|
// bump is ignored but adding/removing/renaming one still flips.
|
|
return [][2]int{{12, 24}}
|
|
case types.LC_CODE_SIGNATURE, types.LC_FUNCTION_STARTS, types.LC_DATA_IN_CODE,
|
|
types.LC_DYLD_EXPORTS_TRIE, types.LC_DYLD_CHAINED_FIXUPS, types.LC_SEGMENT_SPLIT_INFO,
|
|
types.LC_DYLIB_CODE_SIGN_DRS, types.LC_LINKER_OPTIMIZATION_HINT:
|
|
return [][2]int{{8, 16}} // linkedit_data_command dataoff + datasize
|
|
case types.LC_DYLD_INFO, types.LC_DYLD_INFO_ONLY:
|
|
return [][2]int{{8, 48}} // 5 (offset,size) pairs into linkedit
|
|
case types.LC_SYMTAB:
|
|
return [][2]int{{8, 24}} // symoff, nsyms, stroff, strsize
|
|
case types.LC_DYSYMTAB:
|
|
return [][2]int{{8, sz}} // all index/offset fields are linkedit position
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sectionIncluded(name string, conf *DiffConfig) bool {
|
|
if len(conf.AllowList) > 0 && !slices.Contains(conf.AllowList, name) {
|
|
return false
|
|
}
|
|
if len(conf.BlockList) > 0 && slices.Contains(conf.BlockList, name) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sectionContentHash(s *types.Section) (string, bool) {
|
|
if s == nil || s.Size == 0 {
|
|
return "", false
|
|
}
|
|
// Stream the section through the hasher rather than slurping s.Data():
|
|
// __TEXT and DSC dylib sections are multi-MB, and this runs for every
|
|
// included section of every binary on both sides.
|
|
return streamSHA256(s.Open())
|
|
}
|
|
|
|
func functionContentHashes(m *macho.File, funcs []types.Function, conf *DiffConfig) map[uint64]string {
|
|
if !conf.FuncStarts || len(funcs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
hashes := make(map[uint64]string, len(funcs))
|
|
// secReader reads each containing section's bytes once and slices every
|
|
// function out of that in-memory buffer. Functions arrive address-sorted
|
|
// from LC_FUNCTION_STARTS, so this reads each section at most once instead
|
|
// of issuing a pread per function (the cold-path profile showed ~66% of
|
|
// CPU was GetFunctionData's per-function read, redundant with the section
|
|
// content hash that already read the same code section).
|
|
var r functionSectionReader
|
|
for _, fn := range funcs {
|
|
sec := m.FindSectionForVMAddr(fn.StartAddr)
|
|
if sec != nil && !sectionIncluded(sec.Seg+"."+sec.Name, conf) {
|
|
continue
|
|
}
|
|
if hash, ok := r.hash(m, sec, fn); ok {
|
|
hashes[fn.StartAddr] = hash
|
|
}
|
|
}
|
|
return hashes
|
|
}
|
|
|
|
// functionSectionReader caches the bytes of the section the previous function
|
|
// belonged to, so a run of functions in the same section reads that section
|
|
// only once. It is reset implicitly when a function maps to a different
|
|
// section.
|
|
type functionSectionReader struct {
|
|
sec *types.Section
|
|
data []byte
|
|
}
|
|
|
|
// hash returns sha256(fn's bytes) as hex. It slices the function out of the
|
|
// cached section buffer when possible and falls back to a direct per-function
|
|
// read for functions with no resolvable section or whose range escapes the
|
|
// section bytes. The digest is byte-identical to hashing GetFunctionData(fn).
|
|
func (r *functionSectionReader) hash(m *macho.File, sec *types.Section, fn types.Function) (string, bool) {
|
|
size := fn.EndAddr - fn.StartAddr
|
|
if size == 0 {
|
|
return "", false
|
|
}
|
|
if sec != nil {
|
|
if r.sec != sec {
|
|
data, err := sec.Data()
|
|
if err != nil {
|
|
r.sec, r.data = nil, nil
|
|
} else {
|
|
r.sec, r.data = sec, data
|
|
}
|
|
}
|
|
if r.sec == sec && fn.StartAddr >= sec.Addr {
|
|
off := fn.StartAddr - sec.Addr
|
|
if off+size <= uint64(len(r.data)) {
|
|
sum := sha256.Sum256(r.data[off : off+size])
|
|
return hex.EncodeToString(sum[:]), true
|
|
}
|
|
}
|
|
}
|
|
// Fallback: unknown section, read error, or range outside the section.
|
|
data, err := m.GetFunctionData(fn)
|
|
if err != nil || len(data) == 0 {
|
|
return "", false
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:]), true
|
|
}
|
|
|
|
func appendFunctionHashChange(appendLine func(string), oldInfo, newInfo *DiffInfo, oldFunc, newFunc types.Function) {
|
|
line, ok := functionHashChangeLine(oldInfo, newInfo, oldFunc, newFunc)
|
|
if ok {
|
|
appendLine(line)
|
|
}
|
|
}
|
|
|
|
func functionHashChangeLine(oldInfo, newInfo *DiffInfo, oldFunc, newFunc types.Function) (string, bool) {
|
|
oldHash, newHash, ok := changedFunctionHash(oldInfo, newInfo, oldFunc, newFunc)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if oldFunc.Name != "" && oldFunc.Name == newFunc.Name {
|
|
return fmt.Sprintf("~ %s : sha256 %s -> %s\n", oldFunc.Name, oldHash, newHash), true
|
|
}
|
|
return fmt.Sprintf("~ %s -> %s : sha256 %s -> %s\n", oldFunc.Name, newFunc.Name, oldHash, newHash), true
|
|
}
|
|
|
|
func changedFunctionHash(oldInfo, newInfo *DiffInfo, oldFunc, newFunc types.Function) (string, string, bool) {
|
|
if len(oldInfo.FunctionHashes) == 0 || len(newInfo.FunctionHashes) == 0 {
|
|
return "", "", false
|
|
}
|
|
oldHash := oldInfo.FunctionHashes[oldFunc.StartAddr]
|
|
newHash := newInfo.FunctionHashes[newFunc.StartAddr]
|
|
if oldHash == "" || newHash == "" || oldHash == newHash {
|
|
return "", "", false
|
|
}
|
|
return oldHash, newHash, true
|
|
}
|
|
|
|
// Equal checks if two Info structs are equal
|
|
func (i DiffInfo) Equal(x DiffInfo) bool {
|
|
if len(i.Imports) != len(x.Imports) {
|
|
return false
|
|
}
|
|
for i, imp := range i.Imports {
|
|
if imp != x.Imports[i] {
|
|
return false
|
|
}
|
|
}
|
|
if len(i.Sections) != len(x.Sections) {
|
|
return false
|
|
}
|
|
for i, sec := range i.Sections {
|
|
if sec != x.Sections[i] {
|
|
return false
|
|
}
|
|
}
|
|
if i.Functions != x.Functions {
|
|
return false
|
|
}
|
|
if len(i.Symbols) != len(x.Symbols) {
|
|
return false
|
|
}
|
|
if i.LoadCmdHash != "" && x.LoadCmdHash != "" && i.LoadCmdHash != x.LoadCmdHash {
|
|
return false
|
|
}
|
|
if i.Verbose && x.Verbose {
|
|
if i.Version != x.Version { // (this could be a lie)
|
|
return false
|
|
}
|
|
if i.UUID != x.UUID {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (i *DiffInfo) String() string {
|
|
var out strings.Builder
|
|
out.WriteString(i.Version + "\n")
|
|
for _, sec := range i.Sections {
|
|
if sec.Hash != "" {
|
|
out.WriteString(fmt.Sprintf(" %s: %#x sha256:%s\n", sec.Name, sec.Size, sec.Hash))
|
|
continue
|
|
}
|
|
out.WriteString(fmt.Sprintf(" %s: %#x\n", sec.Name, sec.Size))
|
|
}
|
|
slices.Sort(i.Imports)
|
|
for _, i := range i.Imports {
|
|
out.WriteString(fmt.Sprintf(" - %s\n", i))
|
|
}
|
|
out.WriteString(fmt.Sprintf(" UUID: %s\n", i.UUID))
|
|
out.WriteString(fmt.Sprintf(" Functions: %d\n", i.Functions))
|
|
out.WriteString(fmt.Sprintf(" Symbols: %d\n", len(i.Symbols)))
|
|
out.WriteString(fmt.Sprintf(" CStrings: %d\n", len(i.CStrings)))
|
|
return out.String()
|
|
}
|
|
|
|
func (diff *MachoDiff) Generate(prev, next map[string]*DiffInfo, conf *DiffConfig) error {
|
|
|
|
/* DIFF IPSW */
|
|
diff.New = utils.Difference(slices.Collect(maps.Keys(next)), slices.Collect(maps.Keys(prev)))
|
|
diff.Removed = utils.Difference(slices.Collect(maps.Keys(prev)), slices.Collect(maps.Keys(next)))
|
|
// Keys come from map iteration (random order); sort so the rendered diff is
|
|
// deterministic and matches the low-memory path (which already sorts).
|
|
slices.Sort(diff.New)
|
|
slices.Sort(diff.Removed)
|
|
|
|
var err error
|
|
for _, currentFileKey := range slices.Sorted(maps.Keys(next)) {
|
|
dat2 := next[currentFileKey]
|
|
if dat1, ok := prev[currentFileKey]; ok {
|
|
if dat2.Equal(*dat1) {
|
|
continue
|
|
}
|
|
var formatted string
|
|
formatted, err = FormatUpdatedDiff(dat1, dat2, conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if formatted == "" {
|
|
continue
|
|
}
|
|
diff.Updated[currentFileKey] = formatted
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DiffIPSW diffs two IPSW's MachOs
|
|
// DiffIPSW diffs Mach-Os across two IPSW archives. Old-side DiffInfo is
|
|
// written to a temp cache on disk so peak heap stays bounded regardless of
|
|
// IPSW size; new-side DiffInfo is compared incrementally and the per-file
|
|
// diff string is emitted as we go.
|
|
func DiffIPSW(oldIPSW, newIPSW string, conf *DiffConfig) (*MachoDiff, error) {
|
|
diff := &MachoDiff{
|
|
Updated: make(map[string]string),
|
|
}
|
|
cacheDir, err := os.MkdirTemp("", "ipsw_macho_diff_cache")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(cacheDir)
|
|
|
|
prevKeys := make(map[string]bool) // value==true => already matched
|
|
|
|
if err := search.ForEachMachoInIPSW(oldIPSW, conf.PemDB, func(path string, m *macho.File) error {
|
|
prevKeys[path] = false
|
|
return WriteCachedDiffInfo(cacheDir, path, GenerateDiffInfo(m, conf))
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in 'Old' IPSW: %v", err)
|
|
}
|
|
|
|
if err := search.ForEachMachoInIPSW(newIPSW, conf.PemDB, func(path string, m *macho.File) error {
|
|
matched, ok := prevKeys[path]
|
|
if !ok {
|
|
diff.New = append(diff.New, path)
|
|
return nil
|
|
}
|
|
|
|
// If we've already matched this old entry earlier in the walk,
|
|
// skip duplicate occurrences (avoid false-New classification).
|
|
if matched {
|
|
return nil
|
|
}
|
|
|
|
oldInfo, err := ReadCachedDiffInfo(cacheDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newInfo := GenerateDiffInfo(m, conf)
|
|
if newInfo.Equal(*oldInfo) {
|
|
prevKeys[path] = true
|
|
return nil
|
|
}
|
|
formatted, err := FormatUpdatedDiff(oldInfo, newInfo, conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if formatted != "" {
|
|
diff.Updated[path] = formatted
|
|
}
|
|
prevKeys[path] = true
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in 'New' IPSW: %v", err)
|
|
}
|
|
|
|
for path, matched := range prevKeys {
|
|
if !matched {
|
|
diff.Removed = append(diff.Removed, path)
|
|
}
|
|
}
|
|
sort.Strings(diff.New)
|
|
sort.Strings(diff.Removed)
|
|
|
|
return diff, nil
|
|
}
|
|
|
|
// MountRoot is an already-mounted volume to diff. Label is the consumer-facing
|
|
// volume name (unused for machos, which key on bare mount-relative paths, but
|
|
// shared with the file/entitlement walkers).
|
|
type MountRoot struct {
|
|
Label string
|
|
MountPoint string
|
|
}
|
|
|
|
// DiffMounts is the in-mount twin of DiffIPSW: it diffs Mach-Os across volumes
|
|
// that are already mounted (via a mount.Session), walking oldRoots/newRoots in
|
|
// the caller's order so cross-volume last-writer-wins matches DiffIPSW. Old-side
|
|
// DiffInfo is cached on disk to keep peak heap bounded.
|
|
func DiffMounts(oldRoots, newRoots []MountRoot, conf *DiffConfig) (*MachoDiff, error) {
|
|
diff := &MachoDiff{
|
|
Updated: make(map[string]string),
|
|
}
|
|
cacheDir, err := os.MkdirTemp("", "ipsw_macho_diff_cache")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(cacheDir)
|
|
|
|
prevKeys := make(map[string]bool) // value==true => already matched
|
|
|
|
for _, root := range oldRoots {
|
|
if err := search.ForEachMachoInMount(root.MountPoint, func(path string, m *macho.File) error {
|
|
prevKeys[path] = false
|
|
return WriteCachedDiffInfo(cacheDir, path, GenerateDiffInfo(m, conf))
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in 'Old' mount %s: %v", root.MountPoint, err)
|
|
}
|
|
}
|
|
|
|
for _, root := range newRoots {
|
|
if err := search.ForEachMachoInMount(root.MountPoint, func(path string, m *macho.File) error {
|
|
matched, ok := prevKeys[path]
|
|
if !ok {
|
|
diff.New = append(diff.New, path)
|
|
return nil
|
|
}
|
|
// Skip duplicate occurrences of an already-matched old entry.
|
|
if matched {
|
|
return nil
|
|
}
|
|
oldInfo, err := ReadCachedDiffInfo(cacheDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newInfo := GenerateDiffInfo(m, conf)
|
|
if newInfo.Equal(*oldInfo) {
|
|
prevKeys[path] = true
|
|
return nil
|
|
}
|
|
formatted, err := FormatUpdatedDiff(oldInfo, newInfo, conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if formatted != "" {
|
|
diff.Updated[path] = formatted
|
|
}
|
|
prevKeys[path] = true
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in 'New' mount %s: %v", root.MountPoint, err)
|
|
}
|
|
}
|
|
|
|
for path, matched := range prevKeys {
|
|
if !matched {
|
|
diff.Removed = append(diff.Removed, path)
|
|
}
|
|
}
|
|
sort.Strings(diff.New)
|
|
sort.Strings(diff.Removed)
|
|
|
|
return diff, nil
|
|
}
|
|
|
|
// DiffFirmwares diffs two IPSW's im4p firmware Mach-Os. Old-side DiffInfo
|
|
// is cached on disk to keep peak heap bounded.
|
|
func DiffFirmwares(oldIPSW, newIPSW string, conf *DiffConfig) (*MachoDiff, error) {
|
|
diff := &MachoDiff{
|
|
Updated: make(map[string]string),
|
|
}
|
|
cacheDir, err := os.MkdirTemp("", "ipsw_firmware_diff_cache")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(cacheDir)
|
|
|
|
prevKeys := make(map[string]bool) // value==true => already matched
|
|
if err := search.ForEachIm4pInIPSW(oldIPSW, func(path string, m *macho.File) error {
|
|
prevKeys[path] = false
|
|
return WriteCachedDiffInfo(cacheDir, path, GenerateDiffInfo(m, conf))
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse firmwares in 'Old' IPSW: %v", err)
|
|
}
|
|
|
|
if err := search.ForEachIm4pInIPSW(newIPSW, func(path string, m *macho.File) error {
|
|
matched, ok := prevKeys[path]
|
|
if !ok {
|
|
diff.New = append(diff.New, path)
|
|
return nil
|
|
}
|
|
|
|
// If we've already matched this old entry earlier in the walk,
|
|
// skip duplicate occurrences (avoid false-New classification).
|
|
if matched {
|
|
return nil
|
|
}
|
|
|
|
oldInfo, err := ReadCachedDiffInfo(cacheDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newInfo := GenerateDiffInfo(m, conf)
|
|
if newInfo.Equal(*oldInfo) {
|
|
prevKeys[path] = true
|
|
return nil
|
|
}
|
|
formatted, err := FormatUpdatedDiff(oldInfo, newInfo, conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if formatted != "" {
|
|
diff.Updated[path] = formatted
|
|
}
|
|
prevKeys[path] = true
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse firmwares in 'New' IPSW: %v", err)
|
|
}
|
|
|
|
for path, matched := range prevKeys {
|
|
if !matched {
|
|
diff.Removed = append(diff.Removed, path)
|
|
}
|
|
}
|
|
sort.Strings(diff.New)
|
|
sort.Strings(diff.Removed)
|
|
|
|
return diff, nil
|
|
}
|