package diff
import (
"context"
"fmt"
"strings"
"github.com/blacktop/ipsw/internal/diff/storage"
)
// ibootTask owns the iBoot parse plus the per-renderer emission for the
// `### iBoot` section. Parse wraps the existing [Diff.parseIBoot] so
// per-mode behavior (OTA / IPSW) is unchanged.
type ibootTask struct {
d *Diff
// hydrated holds the IBootDiff loaded from a cache hit. Non-nil only on
// the hydrate path; Hydrate publishes it directly to d.IBoot and the
// orchestrator skips Parse. A zero-content hit yields a non-nil empty
// IBootDiff so the hydrate branch is still taken.
hydrated *IBootDiff
}
func newIBootTask(d *Diff) *ibootTask {
return &ibootTask{d: d}
}
// Name returns the stable identifier used for logs and cache scoping.
func (t *ibootTask) Name() string { return "iboot" }
// JSONKey returns the stable public JSON key under which the task's
// payload embeds in the top-level report DTO.
func (t *ibootTask) JSONKey() string { return "iboot" }
// Empty reports whether the task has nothing to render.
func (t *ibootTask) Empty() bool {
if t.d.IBoot == nil {
return true
}
return len(t.d.IBoot.Versions) < 2 && len(t.d.IBoot.New) == 0 && len(t.d.IBoot.Removed) == 0
}
// Parse runs the iBoot enumeration. Wraps the existing [Diff.parseIBoot]
// so per-mode behavior (OTA / IPSW) is unchanged. Skipped by the
// orchestrator on a cache hit (Hydrate publishes the result).
func (t *ibootTask) Parse(_ context.Context, d *Diff) error {
return d.parseIBoot()
}
// ibootCacheVersion is the cache payload / output-semantics version for
// ibootTask. Bump it whenever the persisted row layout (the IBootDiff), the
// iBoot string-diff logic, or the rendered iBoot section semantics change in a
// way that invalidates rows written by a prior ipsw build.
const ibootCacheVersion = 1
// Version reports the cache payload / output-semantics version. See
// ibootCacheVersion.
func (t *ibootTask) Version() int { return ibootCacheVersion }
// OptionsHash digests every output-affecting option for ibootTask. The task has
// no output-affecting flags: parseIBoot always extracts the iBoot strings,
// diffs them with the fixed 10-char minimum-length filter, and renders them
// through the fixed ibootTask render path. 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
// ibootCacheVersion, so the hash folds in that constant version tag alone.
func (t *ibootTask) OptionsHash() string {
return constOptionsHash("iboot-options-v", ibootCacheVersion)
}
// InputHash digests the task-scope inputs: every distinct "iBoot" BuildManifest
// entry digest, sorted, old then new. parseIBoot reads the iBoot im4p straight
// from the IPSW zip (the first member matching iBoot\..*\.im4p), so there is no
// single manifest path to key on; folding every iBoot manifest digest tracks
// any change to the iBoot firmware artifact regardless of which per-device
// variant the zip yields.
func (t *ibootTask) InputHash() string {
return ibootDMGInputHash(t.d.Old.Info, t.d.New.Info)
}
// ibootCacheRowKey is the single row key for the cached IBootDiff.
const ibootCacheRowKey = "iboot"
// Hydrate rebuilds the IBootDiff from a cache hit. The single row holds a
// gob-encoded IBootDiff; the decoded value is stashed in t.hydrated and
// published to d.IBoot so rendering sees the cached state without re-parsing. A
// zero-row hit (the empty-result case) yields a non-nil empty IBootDiff so the
// hydrate branch is still taken and publishes byte-identical empty output.
func (t *ibootTask) Hydrate(scope storage.Scope, store storage.Store) error {
out := &IBootDiff{
New: make(map[string][]string),
Removed: make(map[string][]string),
}
err := store.Iter(scope, func(key string, decode func(v any) error) error {
var diff IBootDiff
if err := decode(&diff); err != nil {
return fmt.Errorf("iboot: hydrate %s: %w", key, err)
}
out = &diff
return nil
})
if err != nil {
return err
}
t.hydrated = out
t.d.IBoot = out
return nil
}
// persistTo writes the IBootDiff from the freshly-parsed Diff. It runs only
// after a successful Parse. An empty result (no version pair and no new/removed
// strings) writes zero rows so a later zero-row Hydrate yields a non-nil empty
// IBootDiff and renders byte-identically to a fresh empty run.
func (t *ibootTask) persistTo(scope storage.Scope, store storage.Store) error {
if t.Empty() {
return nil
}
if err := store.Put(scope, ibootCacheRowKey, t.d.IBoot); err != nil {
return fmt.Errorf("iboot: persist: %w", err)
}
return nil
}
// Markdown emits the iBoot section. The byte sequence must remain
// identical to the prior inlined body in md.go.
func (t *ibootTask) Markdown(w *strings.Builder, _ string) error {
if t.d.IBoot == nil {
return nil
}
if len(t.d.IBoot.Versions) >= 2 {
fmt.Fprintf(w,
"### iBoot\n\n"+
"| iOS | Version |\n"+
"| :-- | :------ |\n"+
"| %s *(%s)* | %s |\n"+
"| %s *(%s)* | %s |\n\n",
t.d.Old.Version, t.d.Old.Build, t.d.IBoot.Versions[0],
t.d.New.Version, t.d.New.Build, t.d.IBoot.Versions[1],
)
}
if len(t.d.IBoot.New) > 0 {
fmt.Fprintf(w, "#### 🆕 NEW (%d)\n\n", len(t.d.IBoot.New))
w.WriteString("View NEW
\n\n")
for k, v := range t.d.IBoot.New {
fmt.Fprintf(w, "##### `%s`\n", k)
for _, str := range v {
fmt.Fprintf(w, " - `%s`\n", str)
}
}
w.WriteString("\nView Removed
\n\n")
for k, v := range t.d.IBoot.Removed {
fmt.Fprintf(w, "##### `%s`\n", k)
for _, str := range v {
fmt.Fprintf(w, " - `%s`\n", str)
}
}
w.WriteString("\n
| iOS | Version |
|---|---|
| {{ .OldVersion }} ({{ .OldBuild }}) | {{ index .IBoot.Versions 0 }} |
| {{ .NewVersion }} ({{ .NewBuild }}) | {{ index .IBoot.Versions 1 }} |
{{ . }}{{ . }}