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.
350 lines
9.3 KiB
Go
350 lines
9.3 KiB
Go
package diff
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/apex/log"
|
|
"github.com/blacktop/go-macho"
|
|
mcmd "github.com/blacktop/ipsw/internal/commands/macho"
|
|
"github.com/blacktop/ipsw/internal/search"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
"github.com/blacktop/ipsw/pkg/aea"
|
|
)
|
|
|
|
type inputMode uint8
|
|
|
|
const (
|
|
inputModeIPSW inputMode = iota
|
|
inputModeDirectory
|
|
inputModeOTA
|
|
)
|
|
|
|
var directoryInputDMGNames = []string{"SystemOS", "AppOS", "FileSystem", "ExclaveOS"}
|
|
|
|
func configureDirectoryContext(ctx *Context) {
|
|
ctx.Version = "Patched OTA"
|
|
ctx.Build = filepath.Base(ctx.IPSWPath)
|
|
ctx.Folder = filepath.Clean(ctx.IPSWPath)
|
|
}
|
|
|
|
func detectInputMode(oldPath, newPath string) (inputMode, error) {
|
|
oldIsDir, err := isDirectory(oldPath)
|
|
if err != nil {
|
|
return inputModeIPSW, fmt.Errorf("failed to stat old input: %w", err)
|
|
}
|
|
newIsDir, err := isDirectory(newPath)
|
|
if err != nil {
|
|
return inputModeIPSW, fmt.Errorf("failed to stat new input: %w", err)
|
|
}
|
|
if oldIsDir != newIsDir {
|
|
return inputModeIPSW, fmt.Errorf("inputs must both be IPSW files or both be directories of patched OTA DMGs")
|
|
}
|
|
if oldIsDir {
|
|
return inputModeDirectory, nil
|
|
}
|
|
return inputModeIPSW, nil
|
|
}
|
|
|
|
func isDirectory(path string) (bool, error) {
|
|
info, err := os.Stat(filepath.Clean(path))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return info.IsDir(), nil
|
|
}
|
|
|
|
// unsupportedFlagsForDirectoryMode returns CLI flag names that are
|
|
// not fully supported when diffing pre-patched directory inputs.
|
|
// These flags are skipped with a warning instead of hard-erroring.
|
|
func unsupportedFlagsForDirectoryMode(conf *Config) []string {
|
|
var unsupported []string
|
|
if conf.LaunchD {
|
|
unsupported = append(unsupported, "--launchd")
|
|
}
|
|
if conf.Sandbox {
|
|
unsupported = append(unsupported, "--sandbox")
|
|
}
|
|
return unsupported
|
|
}
|
|
|
|
func findDirectoryInputDMGs(root string) (map[string]string, error) {
|
|
root = filepath.Clean(root)
|
|
found := make(map[string]string)
|
|
|
|
for _, name := range directoryInputDMGNames {
|
|
var matches []string
|
|
for _, pattern := range []string{"*.dmg", "*.dmg.aea", "*.aea"} {
|
|
hits, err := filepath.Glob(filepath.Join(root, name, pattern))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to glob %s DMGs: %w", name, err)
|
|
}
|
|
matches = append(matches, hits...)
|
|
}
|
|
sort.Strings(matches)
|
|
matches = slices.Compact(matches)
|
|
switch len(matches) {
|
|
case 0:
|
|
continue
|
|
case 1:
|
|
found[name] = matches[0]
|
|
default:
|
|
return nil, fmt.Errorf("found too many %s DMGs in %s", name, root)
|
|
}
|
|
}
|
|
|
|
if len(found) == 0 {
|
|
return nil, fmt.Errorf("no DMGs found under %s", root)
|
|
}
|
|
|
|
return found, nil
|
|
}
|
|
|
|
func mountDirectoryDMGs(ctx *Context) error {
|
|
dmgs, err := findDirectoryInputDMGs(ctx.IPSWPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, ok := dmgs["SystemOS"]; !ok {
|
|
return fmt.Errorf("directory input %s is missing a SystemOS DMG", ctx.IPSWPath)
|
|
}
|
|
|
|
names := make([]string, 0, len(dmgs))
|
|
for name := range dmgs {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
for _, name := range names {
|
|
log.Infof("Mounting %s DMG", name)
|
|
mnt, err := mountNamedDMG(name, dmgs[name], ctx.PemDB)
|
|
if err != nil {
|
|
releaseDirectoryMounts(ctx.Build, ctx.Mount)
|
|
return err
|
|
}
|
|
ctx.Mount[name] = mnt
|
|
if name == "SystemOS" {
|
|
ctx.SystemOsDmgPath = mnt.DmgPath
|
|
ctx.MountPath = mnt.MountPath
|
|
ctx.IsMounted = mnt.IsMounted
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func releaseDirectoryMounts(label string, mounts map[string]mount) {
|
|
if label == "" {
|
|
label = "Directory"
|
|
}
|
|
for _, name := range sortedMountNames(mounts) {
|
|
mnt := mounts[name]
|
|
if mnt.IsMounted {
|
|
utils.Indent(log.Info, 2)(fmt.Sprintf("Leaving '%s' %s DMG mounted", label, name))
|
|
mnt.cleanup()
|
|
continue
|
|
}
|
|
utils.Indent(log.Info, 2)(fmt.Sprintf("Unmounting '%s' %s DMG", 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 DMG: %v", label, name, err))
|
|
}
|
|
mnt.cleanup()
|
|
}
|
|
}
|
|
|
|
func mountNamedDMG(name, path, pemDB string) (mount, error) {
|
|
mnt := mount{
|
|
DmgPath: filepath.Clean(path),
|
|
}
|
|
|
|
if filepath.Ext(mnt.DmgPath) == ".aea" {
|
|
tmpDir, err := os.MkdirTemp("", "ipsw-diff-dmg")
|
|
if err != nil {
|
|
return mnt, fmt.Errorf("failed to create temp dir for %s decryption: %w", name, err)
|
|
}
|
|
decryptedPath, err := aea.Decrypt(&aea.DecryptConfig{
|
|
Input: mnt.DmgPath,
|
|
Output: tmpDir,
|
|
PemDB: pemDB,
|
|
Proxy: "",
|
|
Insecure: false,
|
|
})
|
|
if err != nil {
|
|
_ = os.Remove(tmpDir)
|
|
return mnt, fmt.Errorf("failed to decrypt %s DMG: %w", name, err)
|
|
}
|
|
mnt.DmgPath = decryptedPath
|
|
mnt.CleanupPaths = []string{decryptedPath, tmpDir}
|
|
}
|
|
|
|
utils.Indent(log.Info, 2)(fmt.Sprintf("Mounting %s", mnt.DmgPath))
|
|
mountPoint, alreadyMounted, err := utils.MountDMG(mnt.DmgPath, "")
|
|
if err != nil {
|
|
mnt.cleanup()
|
|
return mnt, fmt.Errorf("failed to mount %s DMG: %w", name, err)
|
|
}
|
|
mnt.MountPath = mountPoint
|
|
mnt.IsMounted = alreadyMounted
|
|
if alreadyMounted {
|
|
utils.Indent(log.Info, 3)(fmt.Sprintf("%s already mounted", mnt.DmgPath))
|
|
}
|
|
|
|
return mnt, nil
|
|
}
|
|
|
|
func (m mount) cleanup() {
|
|
for _, path := range m.CleanupPaths {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
utils.Indent(log.Warn, 3)(fmt.Sprintf("failed to remove %s: %v", path, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func relativeToRoot(root, path string) string {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err == nil && !strings.HasPrefix(rel, "..") {
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
if _, rest, ok := strings.Cut(path, root); ok {
|
|
return filepath.ToSlash(strings.TrimPrefix(rest, string(os.PathSeparator)))
|
|
}
|
|
return filepath.ToSlash(filepath.Base(path))
|
|
}
|
|
|
|
func walkFolderFiles(root string, fn func(relPath, absPath string) error) error {
|
|
return search.WalkFilesInRoot(root, func(absPath string) error {
|
|
return fn(relativeToRoot(root, absPath), absPath)
|
|
})
|
|
}
|
|
|
|
// diffMachosInMounts diffs Mach-Os per volume between the old and new mount
|
|
// sets. Each volume's contents are diffed independently, producing a per-
|
|
// volume map matching the IPSW machosJob output shape so renderers and JSON
|
|
// consumers see the same structure across input modes.
|
|
func diffMachosInMounts(oldMounts, newMounts map[string]mount, conf *mcmd.DiffConfig) (map[string]*mcmd.MachoDiff, error) {
|
|
out := make(map[string]*mcmd.MachoDiff)
|
|
|
|
names := unionMountNames(oldMounts, newMounts)
|
|
for _, name := range names {
|
|
prev := make(map[string]*mcmd.DiffInfo)
|
|
if mnt, ok := oldMounts[name]; ok {
|
|
if err := search.ForEachMacho(mnt.MountPath, func(path string, m *macho.File) error {
|
|
key := filepath.ToSlash(relativeToRoot(mnt.MountPath, path))
|
|
prev[key] = mcmd.GenerateDiffInfo(m, conf)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in old %s mount: %w", name, err)
|
|
}
|
|
}
|
|
|
|
next := make(map[string]*mcmd.DiffInfo)
|
|
if mnt, ok := newMounts[name]; ok {
|
|
if err := search.ForEachMacho(mnt.MountPath, func(path string, m *macho.File) error {
|
|
key := filepath.ToSlash(relativeToRoot(mnt.MountPath, path))
|
|
next[key] = mcmd.GenerateDiffInfo(m, conf)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to parse machos in new %s mount: %w", name, err)
|
|
}
|
|
}
|
|
|
|
if len(prev) == 0 && len(next) == 0 {
|
|
continue
|
|
}
|
|
diff := &mcmd.MachoDiff{Updated: make(map[string]string)}
|
|
if err := diff.Generate(prev, next, conf); err != nil {
|
|
return nil, fmt.Errorf("failed to generate %s machos diff: %w", name, err)
|
|
}
|
|
if machoDiffHasContent(diff) {
|
|
out[name] = diff
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// unionMountNames returns the sorted union of two mount maps' keys.
|
|
func unionMountNames(a, b map[string]mount) []string {
|
|
seen := make(map[string]struct{}, len(a)+len(b))
|
|
for name := range a {
|
|
seen[name] = struct{}{}
|
|
}
|
|
for name := range b {
|
|
seen[name] = struct{}{}
|
|
}
|
|
names := make([]string, 0, len(seen))
|
|
for name := range seen {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func diffFilesInMounts(oldMounts, newMounts map[string]mount) (*FileDiff, error) {
|
|
out := &FileDiff{
|
|
New: make(map[string][]string),
|
|
Removed: make(map[string][]string),
|
|
}
|
|
|
|
prev, err := collectFilesInMounts(oldMounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
next, err := collectFilesInMounts(newMounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dmgNames := make([]string, 0, len(prev))
|
|
for name := range prev {
|
|
dmgNames = append(dmgNames, name)
|
|
}
|
|
for name := range next {
|
|
if !slices.Contains(dmgNames, name) {
|
|
dmgNames = append(dmgNames, name)
|
|
}
|
|
}
|
|
sort.Strings(dmgNames)
|
|
|
|
for _, name := range dmgNames {
|
|
out.New[name] = utils.Difference(next[name], prev[name])
|
|
out.Removed[name] = utils.Difference(prev[name], next[name])
|
|
sort.Strings(out.New[name])
|
|
sort.Strings(out.Removed[name])
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func collectFilesInMounts(mounts map[string]mount) (map[string][]string, error) {
|
|
out := make(map[string][]string)
|
|
for _, name := range sortedMountNames(mounts) {
|
|
mnt := mounts[name]
|
|
if err := walkFolderFiles(mnt.MountPath, func(relPath, _ string) error {
|
|
out[name] = append(out[name], filepath.ToSlash(relPath))
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to walk %s mount: %w", name, err)
|
|
}
|
|
sort.Strings(out[name])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sortedMountNames(mounts map[string]mount) []string {
|
|
names := make([]string, 0, len(mounts))
|
|
for name := range mounts {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|