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

370 lines
9.3 KiB
Go

package storage
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/blacktop/ipsw/pkg/info"
"github.com/blacktop/ipsw/pkg/plist"
)
// fakeInfo builds a minimal *info.Info populated with the BuildManifest fields
// IPSWCacheIdentity reads. extra lets a test mutate a single BuildIdentity
// digest so the resulting identity diverges from the baseline.
func fakeInfo(t *testing.T, product, build, device string, manifestDigest byte) *info.Info {
t.Helper()
return &info.Info{
Plists: &plist.Plists{
BuildManifest: &plist.BuildManifest{
ProductBuildVersion: build,
ProductVersion: product,
SupportedProductTypes: []string{device},
BuildIdentities: []plist.BuildIdentity{
{
Manifest: map[string]plist.IdentityManifest{
"KernelCache": {
Digest: []byte{manifestDigest, 0xAA, 0xBB},
},
},
},
},
},
},
}
}
func TestResolveCachePath(t *testing.T) {
tests := []struct {
name string
old, new string
override string
want string
wantErr bool
}{
{
name: "override directory wins",
old: "old-id",
new: "new-id",
override: "/tmp/custom",
want: "/tmp/custom/old-id__new-id.db",
},
{
name: "empty identity fails",
old: "",
new: "new-id",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ResolveCachePath(tc.old, tc.new, tc.override)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("ResolveCachePath: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestResolveCachePathDefaultDir(t *testing.T) {
got, err := ResolveCachePath("a", "b", "")
if err != nil {
t.Fatalf("ResolveCachePath: %v", err)
}
base, err := DefaultCacheDir()
if err != nil {
t.Fatalf("DefaultCacheDir: %v", err)
}
want := filepath.Join(base, "a__b.db")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestIPSWCacheIdentityComposition(t *testing.T) {
base := fakeInfo(t, "17.0", "21A329", "iPhone15,2", 0x01)
id, err := IPSWCacheIdentity(base)
if err != nil {
t.Fatalf("IPSWCacheIdentity: %v", err)
}
// Comma in device names is sanitized to underscore.
for _, want := range []string{"21A329", "iPhone15_2", "17.0"} {
if !strings.Contains(id, want) {
t.Fatalf("identity %q missing component %q", id, want)
}
}
// Identity changes when the BuildManifest digest changes.
other := fakeInfo(t, "17.0", "21A329", "iPhone15,2", 0xFF)
otherID, err := IPSWCacheIdentity(other)
if err != nil {
t.Fatalf("IPSWCacheIdentity (other): %v", err)
}
if id == otherID {
t.Fatalf("identity collision: %q == %q despite manifest digest change", id, otherID)
}
// Same inputs must produce a stable identity across calls.
again, err := IPSWCacheIdentity(fakeInfo(t, "17.0", "21A329", "iPhone15,2", 0x01))
if err != nil {
t.Fatalf("IPSWCacheIdentity (again): %v", err)
}
if again != id {
t.Fatalf("identity not stable: %q != %q", again, id)
}
}
func TestIPSWCacheIdentityMissingManifest(t *testing.T) {
if _, err := IPSWCacheIdentity(nil); err == nil {
t.Fatal("expected error on nil Info")
}
if _, err := IPSWCacheIdentity(&info.Info{}); err == nil {
t.Fatal("expected error on Info without Plists")
}
}
func TestOpenCacheStoreNoCache(t *testing.T) {
dir := t.TempDir()
store, cleanup, err := OpenCacheStore(CacheOptions{
NoCache: true,
Dir: dir, // ignored when NoCache is true
})
if err != nil {
t.Fatalf("OpenCacheStore: %v", err)
}
if store == nil {
t.Fatal("nil store")
}
if cleanup == nil {
t.Fatal("nil cleanup")
}
scope := baseScope()
if err := store.Put(scope, "row", payload{Path: "x"}); err != nil {
t.Fatalf("Put: %v", err)
}
cleanup()
// Persistent cache dir must remain empty because NoCache=true used a
// temp file path elsewhere.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".db") {
t.Fatalf("unexpected .db file in override dir: %s", e.Name())
}
}
}
func TestOpenCacheStorePersistent(t *testing.T) {
dir := t.TempDir()
store, cleanup, err := OpenCacheStore(CacheOptions{
OldInfo: fakeInfo(t, "17.0", "21A329", "iPhone15,2", 0x01),
NewInfo: fakeInfo(t, "17.1", "21B70", "iPhone15,2", 0x02),
Dir: dir,
})
if err != nil {
t.Fatalf("OpenCacheStore: %v", err)
}
if err := store.Put(baseScope(), "row", payload{Path: "x"}); err != nil {
t.Fatalf("Put: %v", err)
}
cleanup()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
var dbs []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".db") {
dbs = append(dbs, e.Name())
}
}
if len(dbs) != 1 {
t.Fatalf("expected 1 .db file, got %v", dbs)
}
}
func TestOpenCacheStoreClean(t *testing.T) {
dir := t.TempDir()
oldInfo := fakeInfo(t, "17.0", "21A329", "iPhone15,2", 0x01)
newInfo := fakeInfo(t, "17.1", "21B70", "iPhone15,2", 0x02)
oldID, _ := IPSWCacheIdentity(oldInfo)
newID, _ := IPSWCacheIdentity(newInfo)
path, _ := ResolveCachePath(oldID, newID, dir)
// Pre-seed a sentinel file at the resolved path so the test can prove
// --clean removed it.
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, []byte("stale"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
store, cleanup, err := OpenCacheStore(CacheOptions{
OldInfo: oldInfo,
NewInfo: newInfo,
Dir: dir,
Clean: true,
})
if err != nil {
t.Fatalf("OpenCacheStore: %v", err)
}
defer cleanup()
_ = store
// The original file content is gone; a freshly opened SQLite header
// is in its place.
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if strings.HasPrefix(string(got), "stale") {
t.Fatalf("--clean did not remove the existing DB")
}
}
func TestEvictLRU(t *testing.T) {
dir := t.TempDir()
// Create 4 .db files: oldest 100 bytes, then 200, 300, 400 bytes.
now := time.Now()
type spec struct {
name string
size int
age time.Duration
}
specs := []spec{
{"oldest.db", 100, 4 * time.Hour},
{"old.db", 200, 3 * time.Hour},
{"new.db", 300, 2 * time.Hour},
{"newest.db", 400, time.Hour},
}
for _, s := range specs {
p := filepath.Join(dir, s.name)
if err := os.WriteFile(p, make([]byte, s.size), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
mod := now.Add(-s.age)
if err := os.Chtimes(p, mod, mod); err != nil {
t.Fatalf("Chtimes: %v", err)
}
}
// Cap at 500 bytes -> must evict oldest.db (100) and old.db (200),
// leaving new.db (300) + newest.db (400) = 700 ... still over cap.
// After evicting old.db (200) we are at 1000-100-200 = 700 > 500.
// Loop must keep evicting next-oldest until <= cap. So new.db (300)
// must also go, leaving 400 (newest.db).
if err := EvictLRU(dir, 500); err != nil {
t.Fatalf("EvictLRU: %v", err)
}
survivors := listDBs(t, dir)
wantSurvivors := []string{"newest.db"}
if !equalStrings(survivors, wantSurvivors) {
t.Fatalf("survivors=%v want=%v", survivors, wantSurvivors)
}
}
func TestEvictLRUUnderCapNoop(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "small.db"), make([]byte, 10), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := EvictLRU(dir, 1024); err != nil {
t.Fatalf("EvictLRU: %v", err)
}
if got := listDBs(t, dir); !equalStrings(got, []string{"small.db"}) {
t.Fatalf("expected small.db to survive, got %v", got)
}
}
func TestEvictLRUMissingDir(t *testing.T) {
if err := EvictLRU(filepath.Join(t.TempDir(), "nope"), 1024); err != nil {
t.Fatalf("EvictLRU on missing dir: %v", err)
}
}
func TestEvictLRUZeroDisabled(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "big.db")
if err := os.WriteFile(p, make([]byte, 4096), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := EvictLRU(dir, 0); err != nil {
t.Fatalf("EvictLRU(0): %v", err)
}
if _, err := os.Stat(p); err != nil {
t.Fatalf("file evicted with max=0: %v", err)
}
}
// TestEvictLRURemovesSidecars proves that evicting a .db file also removes
// its SQLite -wal and -shm sidecars so the next open does not pick up a
// stale journal pair.
func TestEvictLRURemovesSidecars(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "evict.db")
if err := os.WriteFile(dbPath, make([]byte, 4096), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
for _, suffix := range []string{"-wal", "-shm"} {
if err := os.WriteFile(dbPath+suffix, make([]byte, 32), 0o644); err != nil {
t.Fatalf("WriteFile sidecar: %v", err)
}
}
if err := EvictLRU(dir, 100); err != nil {
t.Fatalf("EvictLRU: %v", err)
}
for _, suffix := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(dbPath + suffix); err == nil {
t.Fatalf("expected %s removed", dbPath+suffix)
}
}
}
func listDBs(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
var out []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".db") {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}