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

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
}