Files
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

978 lines
32 KiB
Go

package diff
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/blacktop/go-macho"
"github.com/blacktop/ipsw/internal/diff/storage"
"github.com/blacktop/ipsw/internal/search"
"github.com/blacktop/ipsw/pkg/info"
"github.com/blacktop/ipsw/pkg/plist"
)
type fakeVolumeFileSession struct {
roots map[string]string
mounted map[string]bool
released []string
// rootCalls counts every Root invocation so a full-cache-hit test can assert
// the orchestrator mounted ZERO volumes (excludeHydrated empties every
// volume's active set, so the loop continues without ever calling Root).
rootCalls int
}
func (f *fakeVolumeFileSession) Root(typ string) (string, error) {
f.rootCalls++
root, ok := f.roots[typ]
if !ok {
return "", errors.New("unexpected root request: " + typ)
}
if f.mounted == nil {
f.mounted = make(map[string]bool)
}
f.mounted[typ] = true
return root, nil
}
func (f *fakeVolumeFileSession) Release(typ string) error {
if !f.mounted[typ] {
return nil
}
delete(f.mounted, typ)
f.released = append(f.released, typ)
return nil
}
func TestIPSWSessionExtractDirsArePerSide(t *testing.T) {
tmpDir := t.TempDir()
oldDir := ipswSessionExtractDir(tmpDir, "old")
newDir := ipswSessionExtractDir(tmpDir, "new")
if oldDir == newDir {
t.Fatalf("old and new extract dirs are equal: %s", oldDir)
}
for _, dir := range []string{oldDir, newDir} {
rel, err := filepath.Rel(tmpDir, dir)
if err != nil {
t.Fatalf("Rel(%s, %s): %v", tmpDir, dir, err)
}
if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
t.Fatalf("extract dir %s is not under temp dir %s", dir, tmpDir)
}
}
dmgName := "shared-volume-name.dmg"
oldDMG := filepath.Join(oldDir, dmgName)
newDMG := filepath.Join(newDir, dmgName)
if oldDMG == newDMG {
t.Fatalf("same-named DMGs collide: %s", oldDMG)
}
}
func TestIndexIdenticalIPSWArtifactsUsesBuildManifestDigests(t *testing.T) {
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"KernelCache": {path: "kernelcache.release.v53", digest: []byte{0x01}},
"OS": {path: "094-55036-099.dmg.aea", digest: []byte{0x02}},
"Cryptex1,SystemOS": {path: "094-55682-100.dmg.aea", digest: []byte{0x03}},
"Cryptex1,AppOS": {path: "094-54871-103.dmg", digest: []byte{0x04}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"KernelCache": {path: "kernelcache.release.v53", digest: []byte{0x01}},
"OS": {path: "094-55036-101.dmg.aea", digest: []byte{0x02}},
"Cryptex1,SystemOS": {path: "094-55682-102.dmg.aea", digest: []byte{0x03}},
"Cryptex1,AppOS": {path: "094-54871-105.dmg", digest: []byte{0x04}},
})
d := &Diff{
Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
New: Context{InputMode: inputModeIPSW, Info: newInfo},
}
d.indexIdenticalIPSWArtifacts()
if !d.sameKernel {
t.Fatal("sameKernel = false, want true")
}
for _, typ := range []string{"fs", "sys", "app"} {
if !d.ipswVolumeUnchanged(typ) {
t.Fatalf("%s unchanged = false, want true", typ)
}
}
if !d.allIPSWOSVolumesUnchanged() {
t.Fatal("allIPSWOSVolumesUnchanged() = false, want true")
}
}
func TestIndexIdenticalIPSWArtifactsFailsClosedOnDigestMismatch(t *testing.T) {
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"KernelCache": {path: "kernelcache.release.v53", digest: []byte{0x01}},
"OS": {path: "094-55036-099.dmg.aea", digest: []byte{0x02}},
"Cryptex1,SystemOS": {path: "094-55682-100.dmg.aea", digest: []byte{0x03}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"KernelCache": {path: "kernelcache.release.v53", digest: []byte{0x09}},
"OS": {path: "094-55036-101.dmg.aea", digest: []byte{0x02}},
"Cryptex1,SystemOS": {path: "094-55682-102.dmg.aea", digest: []byte{0x08}},
})
d := &Diff{
Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
New: Context{InputMode: inputModeIPSW, Info: newInfo},
}
d.indexIdenticalIPSWArtifacts()
if d.sameKernel {
t.Fatal("sameKernel = true, want false")
}
if d.ipswVolumeUnchanged("sys") {
t.Fatal("sys unchanged = true, want false")
}
if d.allIPSWOSVolumesUnchanged() {
t.Fatal("allIPSWOSVolumesUnchanged() = true, want false")
}
}
func TestDSCVolumeUnchangedFallsBackToFilesystemDigest(t *testing.T) {
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "filesystem-old.dmg", digest: []byte{0x02}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "filesystem-new.dmg", digest: []byte{0x02}},
})
d := &Diff{
Old: Context{InputMode: inputModeIPSW, Info: oldInfo},
New: Context{InputMode: inputModeIPSW, Info: newInfo},
}
d.indexIdenticalIPSWArtifacts()
if !d.dscVolumeUnchanged() {
t.Fatal("dscVolumeUnchanged() = false, want true")
}
}
// TestEnsureKernelcachePathsNoOpWhenSet is the regression for the partial-hit
// ordering fix: extractKernelcaches sets Kernel.Path as a side effect of the
// kexts task's Parse, but a warm kexts cache hit SKIPS that Parse, leaving the
// path empty for a sibling task (e.g. a partial-hit sandbox) that runs fresh.
// ensureKernelcachePaths backfills the path; when both sides are already set it
// must be a pure no-op (never re-extract, never touch the paths), which this test
// pins. The extraction branch needs real IPSW artifacts and is covered by the
// real-IPSW integration runs.
func TestEnsureKernelcachePathsNoOpWhenSet(t *testing.T) {
d := &Diff{}
d.Old.Kernel.Path = "/extracted/old/kernelcache"
d.New.Kernel.Path = "/extracted/new/kernelcache"
if err := d.ensureKernelcachePaths(); err != nil {
t.Fatalf("ensureKernelcachePaths() with both paths set = %v, want nil (must be a no-op)", err)
}
if d.Old.Kernel.Path != "/extracted/old/kernelcache" || d.New.Kernel.Path != "/extracted/new/kernelcache" {
t.Fatalf("ensureKernelcachePaths mutated already-set paths: old=%q new=%q",
d.Old.Kernel.Path, d.New.Kernel.Path)
}
}
func TestFilesSHA256Equal(t *testing.T) {
tmpDir := t.TempDir()
oldPath := filepath.Join(tmpDir, "old")
newPath := filepath.Join(tmpDir, "new")
if err := os.WriteFile(oldPath, []byte("same kernel bytes"), 0o644); err != nil {
t.Fatalf("WriteFile(old) error = %v", err)
}
if err := os.WriteFile(newPath, []byte("same kernel bytes"), 0o644); err != nil {
t.Fatalf("WriteFile(new) error = %v", err)
}
same, err := filesSHA256Equal(oldPath, newPath)
if err != nil {
t.Fatalf("filesSHA256Equal() error = %v", err)
}
if !same {
t.Fatal("filesSHA256Equal() = false, want true")
}
if err := os.WriteFile(newPath, []byte("different kernel bytes"), 0o644); err != nil {
t.Fatalf("WriteFile(new different) error = %v", err)
}
same, err = filesSHA256Equal(oldPath, newPath)
if err != nil {
t.Fatalf("filesSHA256Equal() after change error = %v", err)
}
if same {
t.Fatal("filesSHA256Equal() = true, want false")
}
}
func TestVolumeJobOrchestratorScansAndReleasesVolumePairs(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
oldSys := testVolumeDir(t, tmpDir, "old-sys", "old-sys-file")
newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": oldFS,
"sys": oldSys,
}}
newSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": newFS,
"sys": newSys,
}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
"Cryptex1,SystemOS": {path: "old-sys.dmg", digest: []byte{0x02}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x03}},
"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x04}},
})
job := newRecordingFilesJob()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if got, want := oldSession.released, []string{"fs", "sys"}; !slices.Equal(got, want) {
t.Fatalf("old released = %v, want %v", got, want)
}
if got, want := newSession.released, []string{"fs", "sys"}; !slices.Equal(got, want) {
t.Fatalf("new released = %v, want %v", got, want)
}
if !job.finalized {
t.Fatal("Finalize was not called")
}
assertFileSeen(t, job.prev, "filesystem", "old-fs-file")
assertFileSeen(t, job.next, "filesystem", "new-fs-file")
assertFileSeen(t, job.prev, "SystemOS", "old-sys-file")
assertFileSeen(t, job.next, "SystemOS", "new-sys-file")
}
func TestVolumeJobOrchestratorDropsTaskWhenSetupFails(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}}
newSession := &fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
})
failing := newSetupFailingJob()
ok := newRecordingFilesJob()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{failing, ok}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if failing.processed {
t.Fatal("setup-failing task ran ProcessVolume; want it dropped after Setup error")
}
if failing.finalized {
t.Fatal("setup-failing task was finalized; want it dropped after Setup error")
}
if !ok.finalized {
t.Fatal("healthy task was not finalized; one task's setup failure must not poison siblings")
}
assertFileSeen(t, ok.prev, "filesystem", "old-fs-file")
}
// recordingSessionFallbackJob is a sys-only MountTask that opts into the
// session fallback and records the roots it receives.
type recordingSessionFallbackJob struct {
gotOld, gotNew string
processed bool
}
func (j *recordingSessionFallbackJob) Name() string { return "fallback-test" }
func (j *recordingSessionFallbackJob) Needs(typ string) bool { return typ == "sys" }
func (j *recordingSessionFallbackJob) WantsSessionFallback(typ string) bool { return typ == "sys" }
func (j *recordingSessionFallbackJob) Finalize() error { return nil }
func (j *recordingSessionFallbackJob) ProcessVolume(_, oldRoot, newRoot string) error {
j.processed = true
j.gotOld, j.gotNew = oldRoot, newRoot
return nil
}
// recordingPlainSysJob is a sys-only MountTask WITHOUT the fallback opt-in,
// proving the fallback root is handed only to tasks that asked for it.
type recordingPlainSysJob struct {
gotOld, gotNew string
}
func (j *recordingPlainSysJob) Name() string { return "plain-sys-test" }
func (j *recordingPlainSysJob) Needs(typ string) bool { return typ == "sys" }
func (j *recordingPlainSysJob) Finalize() error { return nil }
func (j *recordingPlainSysJob) ProcessVolume(_, oldRoot, newRoot string) error {
j.gotOld, j.gotNew = oldRoot, newRoot
return nil
}
// TestVolumeJobOrchestratorSessionFallbackForMixedPair covers the mixed
// pre-cryptex-vs-cryptex case: the old IPSW has no SystemOS cryptex, so the
// sys phase mounts nothing for it via the strict resolver, but a
// SessionFallbackTask (dscJob in production) receives the session-resolved
// root — the real mount.Session.Root("sys") falls back to the filesystem DMG,
// simulated here by the fake session mapping "sys" to the fs dir. A plain
// task in the same phase still sees the absent side as empty.
func TestVolumeJobOrchestratorSessionFallbackForMixedPair(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": oldFS,
"sys": oldFS, // mount.Session.Root("sys") falls back to the fs DMG
}}
newSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": newFS,
"sys": newSys,
}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x03}},
})
fallback := &recordingSessionFallbackJob{}
plain := &recordingPlainSysJob{}
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{fallback, plain}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if !fallback.processed {
t.Fatal("fallback job never processed the sys volume")
}
if fallback.gotOld != oldFS {
t.Fatalf("fallback job old root = %q, want session-fallback %q", fallback.gotOld, oldFS)
}
if fallback.gotNew != newSys {
t.Fatalf("fallback job new root = %q, want %q", fallback.gotNew, newSys)
}
if plain.gotOld != "" {
t.Fatalf("plain job old root = %q, want empty (no fallback for non-opting tasks)", plain.gotOld)
}
if plain.gotNew != newSys {
t.Fatalf("plain job new root = %q, want %q", plain.gotNew, newSys)
}
if got, want := oldSession.released, []string{"sys"}; !slices.Equal(got, want) {
t.Fatalf("old released = %v, want %v (fallback mount must be released)", got, want)
}
}
func TestVolumeJobOrchestratorReleasesOnlyMountedAsymmetricVolume(t *testing.T) {
tmpDir := t.TempDir()
oldExc := testVolumeDir(t, tmpDir, "old-exc", "old-exc-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{
"exc": oldExc,
}}
newSession := &fakeVolumeFileSession{roots: map[string]string{}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"Ap,ExclaveOS": {path: "old-exc.dmg", digest: []byte{0x01}},
})
newInfo := testIPSWInfo(nil)
job := newRecordingFilesJob()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if got, want := oldSession.released, []string{"exc"}; !slices.Equal(got, want) {
t.Fatalf("old released = %v, want %v", got, want)
}
if len(newSession.released) != 0 {
t.Fatalf("new released = %v, want no releases", newSession.released)
}
assertFileSeen(t, job.prev, "ExclaveOS", "old-exc-file")
if len(job.next) != 0 {
t.Fatalf("new files = %v, want no files", job.next)
}
}
func TestVolumeJobOrchestratorSkipsVolumesWithMatchingDigest(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
oldSys := testVolumeDir(t, tmpDir, "old-sys", "old-sys-file")
newSys := testVolumeDir(t, tmpDir, "new-sys", "new-sys-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": oldFS,
"sys": oldSys,
}}
newSession := &fakeVolumeFileSession{roots: map[string]string{
"fs": newFS,
"sys": newSys,
}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
"Cryptex1,SystemOS": {path: "old-sys.dmg", digest: []byte{0x02}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x03}},
"Cryptex1,SystemOS": {path: "new-sys.dmg", digest: []byte{0x04}},
})
// Pretend fs is unchanged. Only sys should be scanned.
unchanged := func(typ string) bool { return typ == "fs" }
job := newRecordingFilesJob()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, unchanged, []Task{job}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if got, want := oldSession.released, []string{"sys"}; !slices.Equal(got, want) {
t.Fatalf("old released = %v, want %v (fs should be skipped)", got, want)
}
if len(job.prev["filesystem"]) != 0 || len(job.next["filesystem"]) != 0 {
t.Fatalf("fs was scanned despite digest match: prev=%v next=%v", job.prev["filesystem"], job.next["filesystem"])
}
assertFileSeen(t, job.prev, "SystemOS", "old-sys-file")
assertFileSeen(t, job.next, "SystemOS", "new-sys-file")
}
func TestDSCJobOnlyNeedsSysVolume(t *testing.T) {
j := newDSCJob(nil)
if j.Name() != "dsc" {
t.Fatalf("Name() = %q, want dsc", j.Name())
}
for _, typ := range []string{"fs", "app", "exc"} {
if j.Needs(typ) {
t.Errorf("dscJob.Needs(%q) = true, want false", typ)
}
}
if !j.Needs("sys") {
t.Error("dscJob.Needs(\"sys\") = false, want true")
}
if err := j.Finalize(); err != nil {
t.Errorf("Finalize() = %v, want nil", err)
}
}
func TestDSCJobFallsBackToFilesystemWhenSystemOSAbsent(t *testing.T) {
d := &Diff{
Old: Context{Info: testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
})},
New: Context{Info: testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
})},
}
j := newDSCJob(d)
if !j.Needs("fs") {
t.Fatal("dscJob.Needs(\"fs\") = false, want true when SystemOS is absent")
}
if j.Needs("sys") {
t.Fatal("dscJob.Needs(\"sys\") = true, want false when SystemOS is absent")
}
for _, typ := range []string{"app", "exc"} {
if j.Needs(typ) {
t.Fatalf("dscJob.Needs(%q) = true, want false", typ)
}
}
}
func TestLaunchdJobOnlyNeedsFsVolume(t *testing.T) {
j := newLaunchdJob(nil)
if j.Name() != "launchd" {
t.Fatalf("Name() = %q, want launchd", j.Name())
}
for _, typ := range []string{"sys", "app", "exc"} {
if j.Needs(typ) {
t.Errorf("launchdJob.Needs(%q) = true, want false", typ)
}
}
if !j.Needs("fs") {
t.Error("launchdJob.Needs(\"fs\") = false, want true")
}
if err := j.Finalize(); err != nil {
t.Errorf("Finalize() = %v, want nil", err)
}
}
// recordingFilesJob is a minimal MountTask used in orchestrator tests. It
// mirrors filesJob's file-walking behavior without depending on FileDiff
// assembly so the tests focus on orchestration.
type recordingFilesJob struct {
prev map[string][]string
next map[string][]string
finalized bool
}
func newRecordingFilesJob() *recordingFilesJob {
return &recordingFilesJob{
prev: make(map[string][]string),
next: make(map[string][]string),
}
}
func (j *recordingFilesJob) Name() string { return "files-test" }
func (j *recordingFilesJob) Needs(typ string) bool {
switch typ {
case "fs", "sys", "app", "exc":
return true
}
return false
}
func (j *recordingFilesJob) ProcessVolume(typ, oldRoot, newRoot string) error {
label := volumeLabel(typ)
if oldRoot != "" {
if err := search.ForEachFileInMount(oldRoot, label, "", func(dmg, path string) error {
j.prev[dmg] = append(j.prev[dmg], path)
return nil
}); err != nil {
return err
}
}
if newRoot != "" {
if err := search.ForEachFileInMount(newRoot, label, "", func(dmg, path string) error {
j.next[dmg] = append(j.next[dmg], path)
return nil
}); err != nil {
return err
}
}
return nil
}
func (j *recordingFilesJob) Finalize() error {
j.finalized = true
return nil
}
// setupFailingJob is a MountTask whose Setup always errors. The orchestrator
// must drop it before any volume is processed so it never sees ProcessVolume
// or Finalize with half-initialized state.
type setupFailingJob struct {
recordingFilesJob
processed bool
}
func newSetupFailingJob() *setupFailingJob {
return &setupFailingJob{recordingFilesJob: *newRecordingFilesJob()}
}
func (j *setupFailingJob) Name() string { return "setup-failing" }
func (j *setupFailingJob) Setup(storage.Store) error {
return errors.New("setup boom")
}
func (j *setupFailingJob) ProcessVolume(typ, oldRoot, newRoot string) error {
j.processed = true
return j.recordingFilesJob.ProcessVolume(typ, oldRoot, newRoot)
}
// recordingMachoJob is a minimal MachoWalkTask used to verify the
// orchestrator's per-volume BeginVolume/MachoHandler/EndVolume sequencing
// without depending on real Mach-O fixtures. It records the call order so
// the test can assert structure, not heap state.
type recordingMachoJob struct {
beginCalls []string
endCalls []string
handlerOld []string
handlerNew []string
handlerErr error
abortCalls []string
oldCalls int
newCalls int
finalized bool
}
func newRecordingMachoJob() *recordingMachoJob { return &recordingMachoJob{} }
func (j *recordingMachoJob) Name() string { return "machos-test" }
func (j *recordingMachoJob) Needs(typ string) bool {
switch typ {
case "fs", "sys", "app", "exc":
return true
}
return false
}
func (j *recordingMachoJob) BeginVolume(typ string) error {
j.beginCalls = append(j.beginCalls, volumeLabel(typ))
return nil
}
func (j *recordingMachoJob) MachoHandler(typ string, side Side) MachoScanHandler {
label := volumeLabel(typ)
switch side {
case SideOld:
j.handlerOld = append(j.handlerOld, label)
case SideNew:
j.handlerNew = append(j.handlerNew, label)
}
return func(string, *macho.File) error {
switch side {
case SideOld:
j.oldCalls++
case SideNew:
j.newCalls++
}
return j.handlerErr
}
}
func (j *recordingMachoJob) EndVolume(typ string) error {
j.endCalls = append(j.endCalls, volumeLabel(typ))
return nil
}
func (j *recordingMachoJob) AbortVolume(typ string) {
j.abortCalls = append(j.abortCalls, typ)
}
func (j *recordingMachoJob) Finalize() error {
j.finalized = true
return nil
}
func TestVolumeJobOrchestratorRunsMachoWalkTasksPerSide(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
oldSession := &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}}
newSession := &fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x03}},
})
job := newRecordingMachoJob()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, storage.NewMemoryStore()); err != nil {
t.Fatalf("runVolumeJobsAcrossSessions() error = %v", err)
}
if got, want := job.beginCalls, []string{"filesystem"}; !slices.Equal(got, want) {
t.Errorf("beginCalls = %v, want %v", got, want)
}
if got, want := job.endCalls, []string{"filesystem"}; !slices.Equal(got, want) {
t.Errorf("endCalls = %v, want %v", got, want)
}
if got, want := job.handlerOld, []string{"filesystem"}; !slices.Equal(got, want) {
t.Errorf("handlerOld = %v, want %v", got, want)
}
if got, want := job.handlerNew, []string{"filesystem"}; !slices.Equal(got, want) {
t.Errorf("handlerNew = %v, want %v", got, want)
}
if !job.finalized {
t.Error("Finalize was not called")
}
}
func TestRunVolumeTasksAbortsMachoTaskOnHandlerFailure(t *testing.T) {
tmpDir := t.TempDir()
oldRoot := filepath.Join(tmpDir, "old")
newRoot := filepath.Join(tmpDir, "new")
writeMinimalMachO(t, filepath.Join(oldRoot, "usr", "bin", "old-tool"))
writeMinimalMachO(t, filepath.Join(newRoot, "usr", "bin", "new-tool"))
want := errors.New("handler failed")
job := newRecordingMachoJob()
job.handlerErr = want
errs := runVolumeTasks("fs", volumeRoots{old: oldRoot, new: newRoot}, []Task{job})
if len(errs) != 1 {
t.Fatalf("runVolumeTasks errors = %d, want 1 (%v)", len(errs), errs)
}
if !errors.Is(errs[0], want) {
t.Fatalf("runVolumeTasks error = %v, want %v", errs[0], want)
}
if got, want := job.abortCalls, []string{"fs"}; !slices.Equal(got, want) {
t.Fatalf("abortCalls = %v, want %v", got, want)
}
if job.oldCalls != 1 {
t.Fatalf("oldCalls = %d, want 1", job.oldCalls)
}
if job.newCalls != 0 {
t.Fatalf("newCalls = %d, want 0 after old-side abort", job.newCalls)
}
if len(job.endCalls) != 0 {
t.Fatalf("endCalls = %v, want no EndVolume after abort", job.endCalls)
}
}
func TestMachosJobAbortVolumeDropsPartialState(t *testing.T) {
d := &Diff{conf: &Config{}}
job := newMachosJob(d)
job.cacheDir = t.TempDir()
if err := job.BeginVolume("fs"); err != nil {
t.Fatalf("BeginVolume: %v", err)
}
label := volumeLabel("fs")
job.prevKeysByVolume[label]["/usr/bin/old"] = false
job.diffByVolume[label].New = append(job.diffByVolume[label].New, "/usr/bin/new")
job.AbortVolume("fs")
if len(job.volumes) != 0 {
t.Fatalf("volumes = %v, want empty after abort", job.volumes)
}
if _, ok := job.prevKeysByVolume[label]; ok {
t.Fatalf("prevKeysByVolume still has %q after abort", label)
}
if _, ok := job.diffByVolume[label]; ok {
t.Fatalf("diffByVolume still has %q after abort", label)
}
if err := job.Finalize(); err != nil {
t.Fatalf("Finalize: %v", err)
}
if len(d.Machos) != 0 {
t.Fatalf("d.Machos = %v, want no output for aborted volume", d.Machos)
}
}
func writeMinimalMachO(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
// mach_header_64, little-endian, ARM64, MH_EXECUTE, no load commands.
data := []byte{
0xcf, 0xfa, 0xed, 0xfe,
0x0c, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
}
if err := os.WriteFile(path, data, 0o755); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func TestSplitJoinedErrorsAndTaskNameFromError(t *testing.T) {
a := errors.New("foo: boom")
b := errors.New("bar: split")
joined := errors.Join(a, b)
parts := splitJoinedErrors(joined)
if len(parts) != 2 {
t.Fatalf("splitJoinedErrors: len = %d, want 2", len(parts))
}
if name, ok := taskNameFromError(parts[0]); !ok || name != "foo" {
t.Errorf("taskNameFromError(parts[0]) = (%q,%v), want (foo,true)", name, ok)
}
if name, ok := taskNameFromError(parts[1]); !ok || name != "bar" {
t.Errorf("taskNameFromError(parts[1]) = (%q,%v), want (bar,true)", name, ok)
}
if _, ok := taskNameFromError(errors.New("no prefix here")); ok {
t.Error("taskNameFromError accepted an unprefixed error")
}
if _, ok := taskNameFromError(errors.New("__walk__: ouch")); ok {
t.Error("taskNameFromError accepted the walker sentinel")
}
// A non-joined error round-trips as a single element so the caller can
// still attribute walker-level failures without losing them.
parts = splitJoinedErrors(a)
if len(parts) != 1 || parts[0] != a {
t.Errorf("splitJoinedErrors(single) = %v, want [%v]", parts, a)
}
}
// fakeCacheableJob is a MountTask that also implements CacheableTask
// (including persistTo). It records the lifecycle calls the orchestrator makes
// so the cache tests can assert miss -> walk -> persist -> complete on the
// first run and hit -> hydrate -> skip-walk on the second.
type fakeCacheableJob struct {
name string
version int
optsHash string
inputHash string
processed int
finalized int
persisted int
hydrated int
hydrateErr error
}
func (j *fakeCacheableJob) Name() string { return j.name }
func (j *fakeCacheableJob) Needs(typ string) bool { return typ == "fs" }
func (j *fakeCacheableJob) ProcessVolume(typ, oldRoot, newRoot string) error {
j.processed++
return nil
}
func (j *fakeCacheableJob) Finalize() error {
j.finalized++
return nil
}
func (j *fakeCacheableJob) Version() int { return j.version }
func (j *fakeCacheableJob) OptionsHash() string { return j.optsHash }
func (j *fakeCacheableJob) InputHash() string { return j.inputHash }
func (j *fakeCacheableJob) Hydrate(scope storage.Scope, store storage.Store) error {
j.hydrated++
return j.hydrateErr
}
func (j *fakeCacheableJob) persistTo(scope storage.Scope, store storage.Store) error {
j.persisted++
return store.Put(scope, "result", j.name)
}
func TestCacheLifecycleMissThenHit(t *testing.T) {
tmpDir := t.TempDir()
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-fs-file")
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-fs-file")
oldInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "old-fs.dmg", digest: []byte{0x01}},
})
newInfo := testIPSWInfo(map[string]testManifestEntry{
"OS": {path: "new-fs.dmg", digest: []byte{0x02}},
})
store := storage.NewMemoryStore()
job := &fakeCacheableJob{name: "cacheable", version: 1, optsHash: "opts", inputHash: "input"}
newSessions := func() (*fakeVolumeFileSession, *fakeVolumeFileSession) {
return &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}},
&fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
}
// First run: cache is empty, so the task misses, walks, persists, and is
// marked complete.
oldSession, newSession := newSessions()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, store); err != nil {
t.Fatalf("first run error = %v", err)
}
if job.processed != 1 {
t.Fatalf("first run processed = %d, want 1 (miss should walk)", job.processed)
}
if job.persisted != 1 {
t.Fatalf("first run persisted = %d, want 1", job.persisted)
}
if job.hydrated != 0 {
t.Fatalf("first run hydrated = %d, want 0", job.hydrated)
}
scope, ok := taskScope(oldInfo, newInfo, job)
if !ok {
t.Fatal("taskScope returned ok=false for a derivable identity")
}
if done, err := store.Complete(scope); err != nil || !done {
t.Fatalf("store.Complete after first run = (%v,%v), want (true,nil)", done, err)
}
// Second run with the SAME store: the completion sentinel exists, so the
// task hits, hydrates, and skips the volume walk.
oldSession, newSession = newSessions()
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, store); err != nil {
t.Fatalf("second run error = %v", err)
}
if job.processed != 1 {
t.Fatalf("second run processed = %d, want 1 (hit must skip the walk)", job.processed)
}
if job.hydrated != 1 {
t.Fatalf("second run hydrated = %d, want 1", job.hydrated)
}
if job.persisted != 1 {
t.Fatalf("second run persisted = %d, want 1 (hit must not re-persist)", job.persisted)
}
if len(oldSession.released) != 0 {
t.Fatalf("second run mounted/released old volume = %v, want none (hydrated task skips mount)", oldSession.released)
}
}
func TestCacheLifecycleUnderivableIdentityRunsFresh(t *testing.T) {
// Info structs without a BuildManifest: identity is underivable, so the
// task is treated as non-cacheable for the run. (Volume resolution also
// requires a BuildManifest, so the underivable decision is made before
// any walk could happen.)
oldInfo := &info.Info{}
newInfo := &info.Info{}
job := &fakeCacheableJob{name: "cacheable", version: 1, optsHash: "opts", inputHash: "input"}
if _, ok := taskScope(oldInfo, newInfo, job); ok {
t.Fatal("taskScope returned ok=true for an underivable identity")
}
store := storage.NewMemoryStore()
lc := newCacheLifecycle(oldInfo, newInfo, []Task{job}, store)
if lc.isHydrated(job) {
t.Fatal("underivable task was hydrated")
}
if len(lc.scopes) != 0 {
t.Fatalf("underivable task recorded a scope: %v", lc.scopes)
}
// persistAndComplete must be a no-op for the underivable task: it has no
// scope, so it is never persisted nor marked complete.
lc.persistAndComplete(store)
if job.persisted != 0 {
t.Fatalf("underivable task persisted = %d, want 0", job.persisted)
}
}
type testManifestEntry struct {
path string
digest []byte
}
func testIPSWInfo(entries map[string]testManifestEntry) *info.Info {
manifest := make(map[string]plist.IdentityManifest, len(entries))
for key, entry := range entries {
manifest[key] = plist.IdentityManifest{
Digest: entry.digest,
Info: map[string]any{
"Path": entry.path,
},
}
}
return &info.Info{
Plists: &plist.Plists{
BuildManifest: &plist.BuildManifest{
BuildIdentities: []plist.BuildIdentity{
{
Info: plist.IdentityInfo{
DeviceClass: "v53",
Variant: "Customer Erase Install",
},
Manifest: manifest,
},
},
},
},
}
}
func testVolumeDir(t *testing.T, tmpDir, name, fileName string) string {
t.Helper()
dir := filepath.Join(tmpDir, name)
if err := os.MkdirAll(filepath.Join(dir, "System"), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", dir, err)
}
path := filepath.Join(dir, "System", fileName)
if err := os.WriteFile(path, []byte(fileName), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
return dir
}
func assertFileSeen(t *testing.T, files map[string][]string, dmg, want string) {
t.Helper()
if !strings.Contains(strings.Join(files[dmg], ","), want) {
t.Fatalf("%s files = %v, want %s", dmg, files[dmg], want)
}
}