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.
278 lines
11 KiB
Go
278 lines
11 KiB
Go
package diff
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/apex/log"
|
|
"github.com/blacktop/ipsw/internal/diff/storage"
|
|
)
|
|
|
|
// kdksTask owns the KDK structure diff parse plus the per-renderer
|
|
// emission for the `### KDKs` sub-section. Parse wraps the existing
|
|
// [Diff.parseKDKs] so the dwarf-structures pipeline is unchanged.
|
|
type kdksTask struct {
|
|
d *Diff
|
|
}
|
|
|
|
func newKDKsTask(d *Diff) *kdksTask {
|
|
return &kdksTask{d: d}
|
|
}
|
|
|
|
// Name returns the stable identifier used for logs and cache scoping.
|
|
func (t *kdksTask) Name() string { return "kdks" }
|
|
|
|
// JSONKey returns the stable public JSON key under which the task's
|
|
// payload embeds in the top-level report DTO.
|
|
func (t *kdksTask) JSONKey() string { return "kdks" }
|
|
|
|
// Empty reports whether the task has nothing to render.
|
|
func (t *kdksTask) Empty() bool { return t.d.KDKs == "" }
|
|
|
|
// Parse runs the KDK structures diff. Wraps the existing [Diff.parseKDKs]
|
|
// so the dwarf parse + markdown rendering pipeline is unchanged.
|
|
func (t *kdksTask) Parse(_ context.Context, d *Diff) error {
|
|
return d.parseKDKs()
|
|
}
|
|
|
|
// kdksCacheVersion is the cache payload / output-semantics version for
|
|
// kdksTask. Bump it whenever the persisted row layout (the rendered KDK-diff
|
|
// string), the dwarf-structures diff pipeline, or the rendered `### KDKs`
|
|
// sub-section semantics change in a way that invalidates rows written by a prior
|
|
// ipsw build.
|
|
const kdksCacheVersion = 1
|
|
|
|
// Version reports the cache payload / output-semantics version. See
|
|
// kdksCacheVersion.
|
|
func (t *kdksTask) Version() int { return kdksCacheVersion }
|
|
|
|
// OptionsHash digests every output-affecting option for kdksTask. The task has
|
|
// no output-affecting flags: parseKDKs always renders the dwarf-structures diff
|
|
// with the fixed dwarf.Config (Markdown=true, Color=false, DiffTool="git"). There
|
|
// are no allow/block lists, no verbosity, and no diff-tool selection. The only
|
|
// thing that can change the rendered bytes is the parse/diff/render logic itself,
|
|
// tracked by kdksCacheVersion, so the hash folds in that constant version tag
|
|
// alone.
|
|
func (t *kdksTask) OptionsHash() string {
|
|
return constOptionsHash("kdks-options-v", kdksCacheVersion)
|
|
}
|
|
|
|
// kdkDwarfPath resolves a user-passed --kdk path to the dSYM DWARF binary that
|
|
// dwarf.DiffStructures actually reads. parseKDKs rewrites the raw --kdk path
|
|
// (a kernel stub Mach-O, e.g. .../Kernels/kernel.release.t6000) to its sibling
|
|
// dSYM DWARF binary (.../kernel.release.t6000.dSYM/Contents/Resources/DWARF/
|
|
// kernel.release.t6000) before diffing, so the bytes that drive the diff live in
|
|
// that dSYM, NOT the stub. InputHash must stat the same resolved file or it
|
|
// fingerprints the wrong artifact (the stub is ~16 MB; the dSYM DWARF is ~75 MB).
|
|
// A path already pointing inside a DWARF dir is returned unchanged; an empty path
|
|
// stays empty so the inert no-KDK case is never transformed or stat'd.
|
|
func kdkDwarfPath(path string) string {
|
|
if path == "" || strings.Contains(path, ".dSYM/Contents/Resources/DWARF") {
|
|
return path
|
|
}
|
|
return filepath.Join(path+".dSYM/Contents/Resources/DWARF", filepath.Base(path))
|
|
}
|
|
|
|
// kdkDisplayPath normalizes a KDK path to the display form parseKDKs
|
|
// publishes on d.Old.KDK / d.New.KDK after diffing (and that the KDK.md
|
|
// renderer embeds): the KDK-relative stub path with the
|
|
// /Library/Developer/KDKs/ prefix and any dSYM DWARF suffix stripped, e.g.
|
|
// "KDK_26.0_25A5279m.kdk/System/Library/Kernels/kernel.release.t6000".
|
|
// Idempotent, so it is safe on raw CLI paths AND already-normalized ones.
|
|
// kdksTask.Hydrate applies it too: a warm hit skips parseKDKs, and without
|
|
// the normalization the cached KDK.md would embed the raw CLI paths and
|
|
// differ from a cold run's output.
|
|
func kdkDisplayPath(path string) string {
|
|
path = strings.TrimPrefix(kdkDwarfPath(path), "/Library/Developer/KDKs/")
|
|
path, _, _ = strings.Cut(path, ".dSYM/Contents/Resources/DWARF")
|
|
return path
|
|
}
|
|
|
|
// kdkFileIdentity returns a cheap content identity for the KDK file at path: its
|
|
// size and modification time. It is a package var so tests can substitute a fake
|
|
// without writing real KDK files (which are multi-GB dSYM DWARF binaries). ok is
|
|
// false when the path does not resolve to a regular file.
|
|
//
|
|
// The identity is deliberately cheap (stat, not a full content hash): a KDK dSYM
|
|
// DWARF binary is multiple gigabytes, and hashing both sides on every run would
|
|
// dominate the diff. Limitation: size + mtime can collide if a KDK is replaced
|
|
// in place with a same-size build and its mtime is reset (e.g. via `touch -r` or
|
|
// a tar extraction preserving timestamps). In practice KDKs are versioned,
|
|
// immutable installs under /Library/Developer/KDKs, so a content change moves the
|
|
// path (a new KDK version) or the mtime (a fresh install); a same-path,
|
|
// same-size, same-mtime replacement is treated as unchanged. Bump kdksCacheVersion
|
|
// or pass a fresh --cache-dir if a stale KDK result is ever observed.
|
|
var kdkFileIdentity = func(path string) (size int64, modTimeUnixNano int64, ok bool) {
|
|
fi, err := os.Stat(path)
|
|
if err != nil || !fi.Mode().IsRegular() {
|
|
return 0, 0, false
|
|
}
|
|
return fi.Size(), fi.ModTime().UnixNano(), true
|
|
}
|
|
|
|
// InputHash digests the EXTERNAL KDK inputs: the user-passed d.Old.KDK and
|
|
// d.New.KDK filesystem paths plus, for each side, the cheap content identity
|
|
// (size + mtime via kdkFileIdentity) of the dSYM DWARF binary the diff actually
|
|
// reads — NOT the raw --kdk stub path. parseKDKs rewrites the stub path to its
|
|
// sibling dSYM DWARF binary before dwarf.DiffStructures reads it, so InputHash
|
|
// resolves the same dSYM via kdkDwarfPath and stats THAT file; statting the stub
|
|
// would fingerprint the wrong artifact (the stub is ~16 MB, the dSYM ~75 MB) and
|
|
// miss any debug-info change that leaves the stub untouched. KDKs are NOT read
|
|
// from the IPSW — they are loose files on disk the user supplies with --kdk — so
|
|
// no BuildManifest digest covers them. The raw path is also folded so pointing at
|
|
// a different KDK version moves the hash even if two dSYMs share a size+mtime.
|
|
// When a KDK path is empty (no --kdk passed), kdksTask is inert and Parse
|
|
// produces nothing; an empty path folds a stable absent marker and is never
|
|
// stat'd, so the hash is deterministic for the inert case. See kdkFileIdentity
|
|
// for the cheap-identity rationale and its same-size/same-mtime collision
|
|
// limitation.
|
|
func (t *kdksTask) InputHash() string {
|
|
h := sha256.New()
|
|
writeKDKIdentity(h, "old", t.d.Old.KDK)
|
|
writeKDKIdentity(h, "new", t.d.New.KDK)
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// writeKDKIdentity folds one side's raw KDK path plus the cheap file identity of
|
|
// the resolved dSYM DWARF binary into h. The raw path is folded so a different
|
|
// KDK version moves the hash; the identity is taken from kdkDwarfPath(path) — the
|
|
// exact file dwarf.DiffStructures reads — so a debug-info change is never missed.
|
|
// An empty path (no --kdk) or an unresolvable file writes a stable absent marker
|
|
// so the inert case is deterministic and two runs without --kdk agree.
|
|
func writeKDKIdentity(h io.Writer, side, path string) {
|
|
_, _ = h.Write([]byte(side))
|
|
_, _ = h.Write([]byte{0})
|
|
_, _ = h.Write([]byte(path))
|
|
_, _ = h.Write([]byte{0})
|
|
if path == "" {
|
|
_, _ = h.Write([]byte{0x00}) // absent marker
|
|
return
|
|
}
|
|
size, modTime, ok := kdkFileIdentity(kdkDwarfPath(path))
|
|
if !ok {
|
|
_, _ = h.Write([]byte{0x00}) // absent marker
|
|
return
|
|
}
|
|
_, _ = h.Write([]byte{0x01}) // present marker
|
|
var b [16]byte
|
|
binary.BigEndian.PutUint64(b[:8], uint64(size))
|
|
binary.BigEndian.PutUint64(b[8:], uint64(modTime))
|
|
_, _ = h.Write(b[:])
|
|
}
|
|
|
|
// kdksCacheRowKey is the single row key for the cached KDK-diff string.
|
|
const kdksCacheRowKey = "kdks"
|
|
|
|
// Hydrate rebuilds the KDK-diff string from a cache hit. The single row holds
|
|
// the rendered string; the decoded value is published to d.KDKs so rendering
|
|
// sees the cached state without re-parsing. A zero-row hit (the empty-result
|
|
// case, where no --kdk was passed or the structures matched) leaves d.KDKs the
|
|
// empty string, so the hit path renders byte-identically to a fresh empty run.
|
|
//
|
|
// The KDK.md side-effect file embeds d.Old.KDK / d.New.KDK, which parseKDKs
|
|
// normalizes to display form as a side effect. A warm hit skips Parse, so
|
|
// Hydrate applies the same kdkDisplayPath normalization here — otherwise the
|
|
// cached run's KDK.md would embed the raw CLI paths and differ from a cold
|
|
// run with identical inputs.
|
|
func (t *kdksTask) Hydrate(scope storage.Scope, store storage.Store) error {
|
|
var out string
|
|
err := store.Iter(scope, func(key string, decode func(v any) error) error {
|
|
var s string
|
|
if err := decode(&s); err != nil {
|
|
return fmt.Errorf("kdks: hydrate %s: %w", key, err)
|
|
}
|
|
out = s
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.d.KDKs = out
|
|
t.d.Old.KDK = kdkDisplayPath(t.d.Old.KDK)
|
|
t.d.New.KDK = kdkDisplayPath(t.d.New.KDK)
|
|
return nil
|
|
}
|
|
|
|
// persistTo writes the KDK-diff string from the freshly-parsed Diff. It runs only
|
|
// after a successful Parse. An empty result (no --kdk passed, or the dwarf
|
|
// structures matched) writes zero rows so a later zero-row Hydrate leaves d.KDKs
|
|
// the empty string, matching a fresh empty run.
|
|
func (t *kdksTask) persistTo(scope storage.Scope, store storage.Store) error {
|
|
if t.Empty() {
|
|
return nil
|
|
}
|
|
if err := store.Put(scope, kdksCacheRowKey, t.d.KDKs); err != nil {
|
|
return fmt.Errorf("kdks: persist: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Markdown emits the KDKs sub-section. The byte sequence must remain
|
|
// identical to the prior inlined body in md.go: writes the inline link
|
|
// into the README and the dedicated KDK.md side-effect file under
|
|
// outputDir.
|
|
func (t *kdksTask) Markdown(w *strings.Builder, outputDir string) error {
|
|
if t.Empty() {
|
|
return nil
|
|
}
|
|
w.WriteString("### KDKs\n\n")
|
|
fname := filepath.Join(outputDir, "KDK.md")
|
|
log.Debugf("Creating diff KDK Markdown: %s", fname)
|
|
f, err := os.Create(fname)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create diff KDK Markdown: %w", err)
|
|
}
|
|
fmt.Fprintf(f, "## KDKs\n\n"+
|
|
"- `%s`\n"+
|
|
"- `%s`\n\n",
|
|
t.d.Old.KDK, t.d.New.KDK,
|
|
)
|
|
fmt.Fprintf(f, "%s", t.d.KDKs)
|
|
f.Close()
|
|
w.WriteString(fmt.Sprintf("- [%s](%s)\n\n", "KDK DIFF", "KDK.md"))
|
|
return nil
|
|
}
|
|
|
|
// HTML returns the per-task HTML fragment Body for the `KDKs` section.
|
|
// Mirrors the outer template slice it replaces:
|
|
//
|
|
// {{- if .KDKs }}
|
|
// <h2 id="kdks">KDKs</h2>
|
|
// {{ .KDKs }}
|
|
// {{- end }}
|
|
//
|
|
// The leading "\n " (newline + 10-space indent) ensures the
|
|
// outer `{{- if not .KDKsFragment.Empty }}{{ .KDKsFragment.Body }}{{- end }}`
|
|
// splice produces byte-identical output.
|
|
func (t *kdksTask) HTML() (HTMLFragment, error) {
|
|
if t.Empty() {
|
|
return HTMLFragment{Heading: "KDKs"}, nil
|
|
}
|
|
rendered := renderMarkdownFragment(t.d.KDKs)
|
|
body := template.HTML("\n <h2 id=\"kdks\">KDKs</h2>\n " + string(rendered))
|
|
return HTMLFragment{Heading: "KDKs", Body: body}, nil
|
|
}
|
|
|
|
// JSON returns the per-task report payload: the rendered KDKs diff string
|
|
// embedded under [kdksTask.JSONKey] in the top-level report DTO. Returns
|
|
// the underlying string as-is so buildReport's omitempty handling matches
|
|
// the legacy `Diff.KDKs` field encoding.
|
|
func (t *kdksTask) JSON() any {
|
|
return t.d.KDKs
|
|
}
|
|
|
|
// Compile-time assertions: kdksTask satisfies the top-level task lifecycle and
|
|
// the cache contract; its render surface mirrors the per-section renderers.
|
|
var (
|
|
_ TopLevelTask = (*kdksTask)(nil)
|
|
_ CacheableTask = (*kdksTask)(nil)
|
|
)
|