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.
370 lines
9.3 KiB
Go
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
|
|
}
|