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.
245 lines
8.2 KiB
Go
245 lines
8.2 KiB
Go
package diff
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"testing"
|
|
|
|
"github.com/blacktop/go-macho"
|
|
"github.com/blacktop/ipsw/internal/diff/storage"
|
|
"github.com/blacktop/ipsw/internal/search"
|
|
)
|
|
|
|
// idempotencyJob is an end-to-end fake CacheableTask that mirrors the
|
|
// machos/ents shape: on a cache miss it walks a volume, derives a per-volume
|
|
// result keyed by volume label, and publishes it (to j.published) in Finalize;
|
|
// on a cache hit it hydrates the same result from the store and publishes that
|
|
// instead. Unlike fakeCacheableJob in diff_test.go (which only records call
|
|
// counts), this job carries a real output so a test can assert the hydrated
|
|
// result equals the freshly-walked one byte-for-byte — the regression guard for
|
|
// the "Finalize skipped for hydrated tasks => empty output on every cache hit"
|
|
// bug.
|
|
//
|
|
// It registers as a MachoWalkTask so the orchestrator drives it through the
|
|
// shared per-volume walk (BeginVolume/MachoHandler/EndVolume) exactly like the
|
|
// production jobs, and so "walk skipped" is observable as zero handler calls on
|
|
// the hit path.
|
|
type idempotencyJob struct {
|
|
optsHash string
|
|
inputHash string
|
|
|
|
// walked counts how many Mach-Os the per-side handlers consumed across the
|
|
// whole run. A cache hit must leave this at 0 because the orchestrator
|
|
// excludes a hydrated task from the volume walk.
|
|
walked int
|
|
// hydrateCalls counts Hydrate invocations so a test can assert the hit path
|
|
// rebuilt state from the store.
|
|
hydrateCalls int
|
|
// persistCalls counts persistTo invocations so a test can assert a hit does
|
|
// not re-persist.
|
|
persistCalls int
|
|
|
|
volumes []string
|
|
byVolume map[string]string
|
|
hydrated map[string]string
|
|
|
|
// published is the per-volume result Finalize emits: the freshly-walked
|
|
// byVolume map on a miss, or the hydrated map on a hit. A test asserts the
|
|
// two are byte-for-byte equal across the cache boundary. Holding the result
|
|
// on the job (not the Diff) keeps the production Diff struct free of
|
|
// test-only fields.
|
|
published map[string]string
|
|
}
|
|
|
|
func newIdempotencyJob(optsHash, inputHash string) *idempotencyJob {
|
|
return &idempotencyJob{
|
|
optsHash: optsHash,
|
|
inputHash: inputHash,
|
|
byVolume: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (j *idempotencyJob) Name() string { return "idempotency" }
|
|
|
|
func (j *idempotencyJob) Needs(typ string) bool { return typ == "fs" }
|
|
|
|
func (j *idempotencyJob) BeginVolume(typ string) error {
|
|
label := volumeLabel(typ)
|
|
trackVolumeOnce(&j.volumes, label)
|
|
return nil
|
|
}
|
|
|
|
func (j *idempotencyJob) MachoHandler(typ string, side Side) search.MachoScanHandler {
|
|
if side != SideNew {
|
|
// Only the new side produces output for this fake; the old side is a
|
|
// no-op so the per-volume result is deterministic regardless of walk
|
|
// order.
|
|
return func(string, *macho.File) error {
|
|
j.walked++
|
|
return nil
|
|
}
|
|
}
|
|
label := volumeLabel(typ)
|
|
return func(path string, _ *macho.File) error {
|
|
j.walked++
|
|
j.byVolume[label] = fmt.Sprintf("walked %s @ %s", label, path)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (j *idempotencyJob) EndVolume(typ string) error { return nil }
|
|
|
|
func (j *idempotencyJob) Finalize() error {
|
|
if j.hydrated != nil {
|
|
j.published = j.hydrated
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(j.volumes))
|
|
maps.Copy(out, j.byVolume)
|
|
j.published = out
|
|
return nil
|
|
}
|
|
|
|
func (j *idempotencyJob) Version() int { return 1 }
|
|
func (j *idempotencyJob) OptionsHash() string { return j.optsHash }
|
|
func (j *idempotencyJob) InputHash() string { return j.inputHash }
|
|
|
|
func (j *idempotencyJob) Hydrate(scope storage.Scope, store storage.Store) error {
|
|
j.hydrateCalls++
|
|
out := make(map[string]string)
|
|
err := store.Iter(scope, func(key string, decode func(v any) error) error {
|
|
var rendered string
|
|
if err := decode(&rendered); err != nil {
|
|
return err
|
|
}
|
|
out[key] = rendered
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
j.hydrated = out
|
|
return nil
|
|
}
|
|
|
|
func (j *idempotencyJob) persistTo(scope storage.Scope, store storage.Store) error {
|
|
j.persistCalls++
|
|
for label, rendered := range j.published {
|
|
if err := store.Put(scope, label, rendered); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestCacheIdempotencyEndToEnd drives the full orchestrator lifecycle four
|
|
// times to lock the idempotency contract:
|
|
//
|
|
// 1. Fresh store -> MISS: the task walks, persists, and is marked complete.
|
|
// 2. Same store -> HIT : the task hydrates, the walk is skipped, and the
|
|
// hydrated output equals run 1's output byte-for-byte.
|
|
// 3. Same store,
|
|
// mutated opts -> MISS: a changed OptionsHash invalidates the scope, so
|
|
// the task walks again instead of serving stale bytes.
|
|
// 4. Fresh store
|
|
// (== --clean) -> MISS: a fresh DB has no completion sentinel, so the task
|
|
// walks again.
|
|
func TestCacheIdempotencyEndToEnd(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
oldFS := testVolumeDir(t, tmpDir, "old-fs", "old-tool")
|
|
newFS := testVolumeDir(t, tmpDir, "new-fs", "new-tool")
|
|
writeMinimalMachO(t, oldFS+"/usr/bin/old-mach")
|
|
writeMinimalMachO(t, newFS+"/usr/bin/new-mach")
|
|
|
|
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}},
|
|
})
|
|
|
|
newSessions := func() (*fakeVolumeFileSession, *fakeVolumeFileSession) {
|
|
return &fakeVolumeFileSession{roots: map[string]string{"fs": oldFS}},
|
|
&fakeVolumeFileSession{roots: map[string]string{"fs": newFS}}
|
|
}
|
|
run := func(store storage.Store, optsHash string) *idempotencyJob {
|
|
job := newIdempotencyJob(optsHash, "input-v1")
|
|
oldSession, newSession := newSessions()
|
|
if err := runVolumeJobsAcrossSessions(oldInfo, newInfo, oldSession, newSession, nil, []Task{job}, store); err != nil {
|
|
t.Fatalf("runVolumeJobsAcrossSessions: %v", err)
|
|
}
|
|
return job
|
|
}
|
|
|
|
shared := storage.NewMemoryStore()
|
|
|
|
// Run 1: cache MISS against a fresh store.
|
|
first := run(shared, "opts-v1")
|
|
if first.walked == 0 {
|
|
t.Fatal("run 1 (miss) did not walk any Mach-Os")
|
|
}
|
|
if first.hydrateCalls != 0 {
|
|
t.Fatalf("run 1 hydrateCalls = %d, want 0 (a fresh store is a miss)", first.hydrateCalls)
|
|
}
|
|
if first.persistCalls != 1 {
|
|
t.Fatalf("run 1 persistCalls = %d, want 1", first.persistCalls)
|
|
}
|
|
wantResult := first.published
|
|
if len(wantResult) == 0 {
|
|
t.Fatal("run 1 produced no result to cache")
|
|
}
|
|
|
|
scope, ok := taskScope(oldInfo, newInfo, first)
|
|
if !ok {
|
|
t.Fatal("taskScope returned ok=false for a derivable identity")
|
|
}
|
|
if done, err := shared.Complete(scope); err != nil || !done {
|
|
t.Fatalf("Complete after run 1 = (%v,%v), want (true,nil)", done, err)
|
|
}
|
|
|
|
// Run 2: cache HIT against the same store. The task must hydrate, skip the
|
|
// walk entirely, and republish output identical to run 1.
|
|
second := run(shared, "opts-v1")
|
|
if second.walked != 0 {
|
|
t.Fatalf("run 2 (hit) walked %d Mach-Os, want 0 (hydrated task skips the walk)", second.walked)
|
|
}
|
|
if second.hydrateCalls != 1 {
|
|
t.Fatalf("run 2 hydrateCalls = %d, want 1", second.hydrateCalls)
|
|
}
|
|
if second.persistCalls != 0 {
|
|
t.Fatalf("run 2 persistCalls = %d, want 0 (a hit must not re-persist)", second.persistCalls)
|
|
}
|
|
if !maps.Equal(second.published, wantResult) {
|
|
t.Fatalf("run 2 (hit) output != run 1 (miss) output:\n hit =%v\n miss=%v",
|
|
second.published, wantResult)
|
|
}
|
|
|
|
// Run 3: mutate an OptionsHash input. The changed scope has no completion
|
|
// sentinel, so the task MISSES and walks again rather than serving stale
|
|
// bytes keyed under the old options.
|
|
third := run(shared, "opts-v2")
|
|
if third.walked == 0 {
|
|
t.Fatal("run 3 (changed options) did not walk; a stale result was served")
|
|
}
|
|
if third.hydrateCalls != 0 {
|
|
t.Fatalf("run 3 hydrateCalls = %d, want 0 (changed options must miss)", third.hydrateCalls)
|
|
}
|
|
if third.persistCalls != 1 {
|
|
t.Fatalf("run 3 persistCalls = %d, want 1 (a fresh walk persists)", third.persistCalls)
|
|
}
|
|
|
|
// Run 4: simulate --clean by handing the task a fresh store. No completion
|
|
// sentinel exists, so the task MISSES and walks.
|
|
clean := run(storage.NewMemoryStore(), "opts-v1")
|
|
if clean.walked == 0 {
|
|
t.Fatal("run 4 (--clean / fresh store) did not walk")
|
|
}
|
|
if clean.hydrateCalls != 0 {
|
|
t.Fatalf("run 4 hydrateCalls = %d, want 0 (a fresh store after --clean is a miss)", clean.hydrateCalls)
|
|
}
|
|
if !maps.Equal(clean.published, wantResult) {
|
|
t.Fatalf("run 4 (clean re-walk) output != run 1 output:\n clean=%v\n miss =%v",
|
|
clean.published, wantResult)
|
|
}
|
|
}
|