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.
402 lines
14 KiB
Go
402 lines
14 KiB
Go
package diff
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
iofs "io/fs"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/apex/log"
|
|
mcmd "github.com/blacktop/ipsw/internal/commands/macho"
|
|
"github.com/blacktop/ipsw/internal/diff/storage"
|
|
"github.com/blacktop/ipsw/pkg/kernelcache"
|
|
"golang.org/x/exp/rand"
|
|
)
|
|
|
|
// kextsTask owns the kernelcache parse plus the per-renderer emission for
|
|
// the `### Kexts` sub-section. Parse wraps the existing
|
|
// [Diff.parseKernelcache] so the kernel symbolicate/diff pipeline is
|
|
// unchanged; the render-time state is the [mcmd.MachoDiff] produced on
|
|
// [Diff.Kexts] by that parse.
|
|
type kextsTask struct {
|
|
d *Diff
|
|
|
|
// hydrated holds the cached kernelcache result loaded from a cache hit.
|
|
// Non-nil only on the hydrate path; Hydrate publishes it directly to the
|
|
// Diff (d.Kexts plus the rendered Kernel version fields) and the
|
|
// orchestrator skips Parse. A zero-content hit still yields a non-nil
|
|
// payload so the hydrate branch is taken.
|
|
hydrated *kextsCachePayload
|
|
}
|
|
|
|
func newKextsTask(d *Diff) *kextsTask {
|
|
return &kextsTask{d: d}
|
|
}
|
|
|
|
// Name returns the stable identifier used for logs and cache scoping.
|
|
func (t *kextsTask) Name() string { return "kexts" }
|
|
|
|
// JSONKey returns the stable public JSON key under which the task's
|
|
// payload embeds in the top-level report DTO.
|
|
func (t *kextsTask) JSONKey() string { return "kexts" }
|
|
|
|
// Empty reports whether the task has nothing to render and the
|
|
// orchestrator should skip the section.
|
|
func (t *kextsTask) Empty() bool {
|
|
return t.d.Kexts == nil ||
|
|
(len(t.d.Kexts.New) == 0 && len(t.d.Kexts.Removed) == 0 && len(t.d.Kexts.Updated) == 0)
|
|
}
|
|
|
|
// Parse runs the kernelcache extraction and diff. Wraps the existing
|
|
// [Diff.parseKernelcache] so behavior (sameKernel short-circuit,
|
|
// signature symbolication, MachO diff) is preserved. Skipped by the
|
|
// orchestrator on a cache hit (Hydrate publishes the result).
|
|
func (t *kextsTask) Parse(_ context.Context, d *Diff) error {
|
|
return d.parseKernelcache()
|
|
}
|
|
|
|
// kextsCacheVersion is the cache payload / output-semantics version for
|
|
// kextsTask. Bump it whenever the persisted payload layout (the kext MachoDiff
|
|
// plus the rendered Kernel version fields, their display date zones, and the
|
|
// sameKernel short-circuit), the kernelcache parse/diff pipeline, the
|
|
// OptionsHash composition, or the rendered Kexts/Kernel section semantics
|
|
// change in a way that invalidates rows written by a prior ipsw build. The
|
|
// feature is unreleased, so it starts at 1; bump only after a release ships.
|
|
const kextsCacheVersion = 1
|
|
|
|
// Version reports the cache payload / output-semantics version. See
|
|
// kextsCacheVersion.
|
|
func (t *kextsTask) Version() int { return kextsCacheVersion }
|
|
|
|
// OptionsHash digests every output-affecting option for the kernelcache diff.
|
|
// parseKernelcache builds a mcmd.DiffConfig inline from d.conf
|
|
// (AllowList/BlockList/CStrings/FuncStarts/Verbose) with the same fixed cosmetic
|
|
// fields machosJob renders with (Markdown=true, Color=false, DiffTool="git"),
|
|
// plus the SymMap derived from d.conf.Signatures. The hash folds the same
|
|
// DiffConfig fields machosJob folds (via hashMachoDiffConfig) — built to mirror
|
|
// the kcmd.Diff config — and additionally folds d.conf.Signatures: the PATH so
|
|
// pointing at a different signatures set moves the hash, and the directory
|
|
// CONTENT identity (per-file rel-path + size + mtime via signaturesDirIdentity)
|
|
// so regenerating the signature files in place at the same path also moves it.
|
|
// Without the content fold, a re-run after `git pull` in the symbolicator repo
|
|
// would hydrate the old symbolicated kext diff and serve stale names.
|
|
func (t *kextsTask) OptionsHash() string {
|
|
h := sha256.New()
|
|
hashMachoDiffConfig(h, t.kextDiffConfig())
|
|
_, _ = h.Write([]byte("Signatures"))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(t.d.conf.Signatures))
|
|
_, _ = h.Write([]byte{0})
|
|
if id, ok := signaturesDirIdentity(t.d.conf.Signatures); ok {
|
|
_, _ = h.Write([]byte{0x01}) // present marker
|
|
_, _ = h.Write(id)
|
|
} else {
|
|
_, _ = h.Write([]byte{0x00}) // absent marker (no --signatures, or unreadable)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// signaturesDirIdentity returns a cheap content identity for the signatures
|
|
// directory: every regular file's slash-relative path, size, and mtime,
|
|
// folded in WalkDir's deterministic lexical order. It is a package var so
|
|
// tests can substitute a fake without writing real signature trees. ok is
|
|
// false for an empty path, an unreadable tree, or a tree with no files.
|
|
//
|
|
// The identity is deliberately cheap (stat, not content hashing): signature
|
|
// sets contain hundreds of JSON files and are re-resolved on every run that
|
|
// misses. Limitation (same as kdkFileIdentity): a file replaced in place with
|
|
// identical size and mtime is treated as unchanged.
|
|
var signaturesDirIdentity = func(dir string) (digest []byte, ok bool) {
|
|
if dir == "" {
|
|
return nil, false
|
|
}
|
|
h := sha256.New()
|
|
found := false
|
|
if err := filepath.WalkDir(dir, func(path string, d iofs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
fi, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found = true
|
|
_, _ = h.Write([]byte(filepath.ToSlash(rel)))
|
|
_, _ = h.Write([]byte{0})
|
|
var b [16]byte
|
|
binary.BigEndian.PutUint64(b[:8], uint64(fi.Size()))
|
|
binary.BigEndian.PutUint64(b[8:], uint64(fi.ModTime().UnixNano()))
|
|
_, _ = h.Write(b[:])
|
|
return nil
|
|
}); err != nil {
|
|
return nil, false
|
|
}
|
|
if !found {
|
|
return nil, false
|
|
}
|
|
return h.Sum(nil), true
|
|
}
|
|
|
|
// kextDiffConfig mirrors the output-affecting fields of the mcmd.DiffConfig
|
|
// parseKernelcache passes to kcmd.Diff. SymMap is omitted: it is derived from
|
|
// d.conf.Signatures, which OptionsHash folds separately, and is unset on the
|
|
// kernelcaches the hash never opens.
|
|
func (t *kextsTask) kextDiffConfig() *mcmd.DiffConfig {
|
|
return &mcmd.DiffConfig{
|
|
Markdown: true,
|
|
Color: false,
|
|
DiffTool: "git",
|
|
AllowList: t.d.conf.AllowList,
|
|
BlockList: t.d.conf.BlockList,
|
|
CStrings: t.d.conf.CStrings,
|
|
FuncStarts: t.d.conf.FuncStarts,
|
|
Verbose: t.d.conf.Verbose,
|
|
}
|
|
}
|
|
|
|
// InputHash digests the task-scope inputs: the old and new BuildManifest
|
|
// KernelCache digests, folded over sorted models, old then new. It reuses the
|
|
// exact digest source ipswKernelcacheManifestDigestsEqual uses for the
|
|
// sameKernel short-circuit, so the cache identity tracks the same kernelcache
|
|
// artifact bytes the orchestrator inspects to skip the diff.
|
|
func (t *kextsTask) InputHash() string {
|
|
return kernelcacheDMGInputHash(t.d.Old.Info, t.d.New.Info)
|
|
}
|
|
|
|
// kextsCachePayload is the single cached row for kextsTask. It carries the kext
|
|
// MachoDiff and both rendered Kernel version structs so a hydrate restores
|
|
// every piece of state parseKernelcache feeds into rendering (the `### Kexts`
|
|
// sub-section AND the `## Kernel` version table). Diff is nil when the kernel
|
|
// is functionally unchanged (the sameKernel short-circuit produces no diff).
|
|
type kextsCachePayload struct {
|
|
Diff *mcmd.MachoDiff
|
|
OldVersion *kernelcache.Version
|
|
NewVersion *kernelcache.Version
|
|
OldDateZoneName string
|
|
OldDateZoneOff int
|
|
NewDateZoneName string
|
|
NewDateZoneOff int
|
|
SameKernel bool
|
|
}
|
|
|
|
// kextsCacheRowKey is the single row key for the cached kernelcache result.
|
|
const kextsCacheRowKey = "kexts"
|
|
|
|
// Hydrate rebuilds the kernelcache result from a cache hit. The single row holds
|
|
// a gob-encoded kextsCachePayload; the decoded value is stashed in t.hydrated
|
|
// and published to the Diff immediately so rendering (which reads d.Kexts and
|
|
// d.Old/New.Kernel.Version) sees the cached state without re-parsing. A
|
|
// zero-row hit (the empty-result case) yields a non-nil empty payload so the
|
|
// hydrate branch is still taken.
|
|
func (t *kextsTask) Hydrate(scope storage.Scope, store storage.Store) error {
|
|
out := &kextsCachePayload{}
|
|
err := store.Iter(scope, func(key string, decode func(v any) error) error {
|
|
var payload kextsCachePayload
|
|
if err := decode(&payload); err != nil {
|
|
return fmt.Errorf("kexts: hydrate %s: %w", key, err)
|
|
}
|
|
out = &payload
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.hydrated = out
|
|
restoreKernelVersionDateZone(out.OldVersion, out.OldDateZoneName, out.OldDateZoneOff)
|
|
restoreKernelVersionDateZone(out.NewVersion, out.NewDateZoneName, out.NewDateZoneOff)
|
|
t.d.Kexts = out.Diff
|
|
t.d.Old.Kernel.Version = out.OldVersion
|
|
t.d.New.Kernel.Version = out.NewVersion
|
|
t.d.sameKernel = out.SameKernel
|
|
return nil
|
|
}
|
|
|
|
// persistTo writes the kernelcache result from the freshly-parsed Diff. It runs
|
|
// only after a successful Parse. An empty result (no kext diff and no kernel
|
|
// version fields, i.e. the sameKernel short-circuit fired) writes zero rows so a
|
|
// later zero-row Hydrate yields a non-nil empty payload and publishes the same
|
|
// nil d.Kexts / nil Kernel.Version a fresh short-circuited run produces.
|
|
func (t *kextsTask) persistTo(scope storage.Scope, store storage.Store) error {
|
|
oldZoneName, oldZoneOff := kernelVersionDateZone(t.d.Old.Kernel.Version)
|
|
newZoneName, newZoneOff := kernelVersionDateZone(t.d.New.Kernel.Version)
|
|
payload := kextsCachePayload{
|
|
Diff: t.d.Kexts,
|
|
OldVersion: t.d.Old.Kernel.Version,
|
|
NewVersion: t.d.New.Kernel.Version,
|
|
OldDateZoneName: oldZoneName,
|
|
OldDateZoneOff: oldZoneOff,
|
|
NewDateZoneName: newZoneName,
|
|
NewDateZoneOff: newZoneOff,
|
|
SameKernel: t.d.sameKernel,
|
|
}
|
|
if !kextsPayloadHasContent(&payload) {
|
|
return nil
|
|
}
|
|
if err := store.Put(scope, kextsCacheRowKey, payload); err != nil {
|
|
return fmt.Errorf("kexts: persist: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// kextsPayloadHasContent reports whether a payload carries any output-bearing
|
|
// state: a non-empty kext MachoDiff or either rendered Kernel version. It
|
|
// mirrors the render-time emptiness checks (kextsTask.Empty for the kext diff;
|
|
// the `## Kernel` section gates on both versions being non-nil) so persistTo's
|
|
// "write only when content-bearing" guard matches what gets rendered.
|
|
func kextsPayloadHasContent(p *kextsCachePayload) bool {
|
|
if p == nil {
|
|
return false
|
|
}
|
|
if p.OldVersion != nil || p.NewVersion != nil {
|
|
return true
|
|
}
|
|
d := p.Diff
|
|
return d != nil && (len(d.New) > 0 || len(d.Removed) > 0 || len(d.Updated) > 0)
|
|
}
|
|
|
|
func kernelVersionDateZone(v *kernelcache.Version) (string, int) {
|
|
if v == nil {
|
|
return "", 0
|
|
}
|
|
return v.KernelVersion.Date.Zone()
|
|
}
|
|
|
|
func restoreKernelVersionDateZone(v *kernelcache.Version, name string, offset int) {
|
|
if v == nil || name == "" {
|
|
return
|
|
}
|
|
date := v.KernelVersion.Date
|
|
v.KernelVersion.Date = time.Date(
|
|
date.Year(), date.Month(), date.Day(),
|
|
date.Hour(), date.Minute(), date.Second(), date.Nanosecond(),
|
|
time.FixedZone(name, offset),
|
|
)
|
|
}
|
|
|
|
// Markdown emits the kexts sub-section. The byte sequence must remain
|
|
// identical to the prior inlined body in md.go.
|
|
func (t *kextsTask) Markdown(w *strings.Builder, outputDir string) error {
|
|
if t.Empty() {
|
|
return nil
|
|
}
|
|
w.WriteString("### Kexts\n\n")
|
|
if len(t.d.Kexts.New) > 0 {
|
|
fmt.Fprintf(w, "#### 🆕 NEW (%d)\n\n", len(t.d.Kexts.New))
|
|
slices.Sort(t.d.Kexts.New)
|
|
for _, k := range t.d.Kexts.New {
|
|
fmt.Fprintf(w, "- `%s`\n", k)
|
|
}
|
|
w.WriteString("\n")
|
|
}
|
|
if len(t.d.Kexts.Removed) > 0 {
|
|
fmt.Fprintf(w, "#### ❌ Removed (%d)\n\n", len(t.d.Kexts.Removed))
|
|
slices.Sort(t.d.Kexts.Removed)
|
|
for _, k := range t.d.Kexts.Removed {
|
|
fmt.Fprintf(w, "- `%s`\n", k)
|
|
}
|
|
w.WriteString("\n")
|
|
}
|
|
if len(t.d.Kexts.Updated) > 0 {
|
|
fmt.Fprintf(w, "### ⬆️ Updated (%d)\n\n", len(t.d.Kexts.Updated))
|
|
w.WriteString("<details>\n" +
|
|
" <summary><i>View Updated</i></summary>\n\n")
|
|
|
|
keys := slices.Collect(maps.Keys(t.d.Kexts.Updated))
|
|
slices.Sort(keys)
|
|
|
|
if len(t.d.Kexts.Updated) < 10 {
|
|
for _, k := range keys {
|
|
fmt.Fprintf(w, "#### %s\n\n", filepath.Base(k))
|
|
fmt.Fprintf(w, "> `%s`\n\n", k)
|
|
fmt.Fprintf(w, "%s\n", t.d.Kexts.Updated[k])
|
|
}
|
|
} else {
|
|
if err := os.MkdirAll(filepath.Join(outputDir, "KEXTS"), 0o750); err != nil {
|
|
return err
|
|
}
|
|
for _, k := range keys {
|
|
fname := filepath.Join(outputDir, "KEXTS", strings.ReplaceAll(filepath.Base(k), " ", "_")+".md")
|
|
if _, err := os.Stat(fname); os.IsExist(err) {
|
|
fname = filepath.Join(outputDir, "KEXTS", fmt.Sprintf("%s.%d.md", strings.ReplaceAll(filepath.Base(k), " ", "_"), rand.Intn(20)))
|
|
}
|
|
log.Debugf("Creating diff kext Markdown file: %s", fname)
|
|
f, err := os.Create(fname)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create diff file: %w", err)
|
|
}
|
|
fmt.Fprintf(f, "## %s\n\n", filepath.Base(k))
|
|
fmt.Fprintf(f, "> `%s`\n\n", k)
|
|
fmt.Fprintf(f, "%s", t.d.Kexts.Updated[k])
|
|
f.Close()
|
|
fmt.Fprintf(w, "- [%s](%s)\n", k, filepath.Join("KEXTS", strings.ReplaceAll(filepath.Base(k), " ", "_")+".md"))
|
|
}
|
|
}
|
|
w.WriteString("\n</details>\n\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// kextsHTMLTemplate renders the kexts HTML body the outer page template
|
|
// previously emitted between
|
|
//
|
|
// {{- if .Kexts }}
|
|
//
|
|
// and
|
|
//
|
|
// {{- end }}
|
|
//
|
|
// for the kexts block. The body keeps the leading `\n ` so the
|
|
// outer template can splice the fragment via
|
|
// `{{- if not .KextsFragment.Empty }}{{ .KextsFragment.Body }}{{- end }}`
|
|
// and produce byte-identical output.
|
|
const kextsHTMLTemplate = `
|
|
<h3 id="kexts">Kexts</h3>
|
|
{{- template "machoDiffSection" dict "Prefix" "kexts" "Diff" . }}`
|
|
|
|
// HTML returns the per-task HTML fragment Body for the `Kexts`
|
|
// sub-section. The outer template splices the body in at the same DOM
|
|
// position the inlined template emitted previously.
|
|
func (t *kextsTask) HTML() (HTMLFragment, error) {
|
|
if t.Empty() {
|
|
return HTMLFragment{Heading: "Kexts"}, nil
|
|
}
|
|
body, err := executeHTMLTaskTemplate("kexts-html", kextsHTMLTemplate, convertMachoDiff(t.d.Kexts))
|
|
if err != nil {
|
|
return HTMLFragment{}, err
|
|
}
|
|
return HTMLFragment{Heading: "Kexts", Body: body}, nil
|
|
}
|
|
|
|
// JSON returns the per-task report payload: the [mcmd.MachoDiff] embedded
|
|
// under [kextsTask.JSONKey] in the top-level report DTO. Returns the
|
|
// underlying pointer as-is so buildReport's omitempty handling matches the
|
|
// legacy `Diff.Kexts` field encoding.
|
|
func (t *kextsTask) JSON() any {
|
|
return t.d.Kexts
|
|
}
|
|
|
|
// Compile-time assertions: kextsTask satisfies the top-level task lifecycle and
|
|
// the cache contract. Its render surface (JSONKey / Empty / Markdown / HTML /
|
|
// JSON) is exercised through buildReport, the Markdown loop in md.go, and
|
|
// renderHTML, mirroring the *strings.Builder Markdown signature the per-section
|
|
// renderers use.
|
|
var (
|
|
_ TopLevelTask = (*kextsTask)(nil)
|
|
_ CacheableTask = (*kextsTask)(nil)
|
|
)
|