package diff import ( "bytes" "encoding/json" "fmt" "html" "html/template" "maps" "os" "path/filepath" "slices" "strings" "github.com/apex/log" mcmd "github.com/blacktop/ipsw/internal/commands/macho" "github.com/blacktop/ipsw/internal/utils" "github.com/gomarkdown/markdown" mdhtml "github.com/gomarkdown/markdown/html" ) // {{ range $index, $element := .Ents }} // - [{{ $element }}](#{{ $element | urlize }}) // {{ end -}} const diffMarkdownTemplate = ` - [{{ .Title }}](#{{ .Title | slug }}) - [Inputs](#inputs) - [Kernel](#kernel) - [Version](#version) {{- if .Kexts }} - [Kexts](#kexts) {{- if .Kexts.New }} - [🆕 NEW ({{ len .Kexts.New }})](#-new) {{- end }} {{- if .Kexts.Removed }} - [❌ Removed ({{ len .Kexts.Removed }})](#-removed) {{- end }} {{- if .Kexts.Updated }} - [⬆️ Updated ({{ len .Kexts.Updated }})](#️-updated) {{- end }} {{- end }} {{- if .Machos }} - [Machos](#machos) {{- end }} {{- if .Ents }} - [🔑 Entitlements](#entitlements) {{- end }} {{- if .Sandbox }} - [Sandbox Profiles](#sandbox-profiles) {{- end }} - [DSC](#dsc) - [WebKit](#webkit) - [Dylibs](#dylibs) {{- if .Dylibs }} {{- if .Dylibs.New }} - [🆕 NEW ({{ len .Dylibs.New }})](#-new-2) {{- end }} {{- if .Dylibs.Removed }} - [❌ Removed ({{ len .Dylibs.Removed }})](#️-removed-2) {{- end }} {{- if .Dylibs.Updated }} - [⬆️ Updated ({{ len .Dylibs.Updated }})](#️-updated-2) {{- end }} {{- end }} # {{ .Title }} ## Inputs - {{ .Old.IPSWPath | base }} - {{ .New.IPSWPath | base }} ## Kernel {{ if .Old.Kernel.Version }} ### Version | iOS | Version | Build | Date | | :-------------------------------------- | :-------------------------------------- | :----------------------------------- | :------------------------------------ | | {{ .Old.Version }} *({{ .Old.Build }})* | {{ .Old.Kernel.Version.KernelVersion.Darwin }} | {{ .Old.Kernel.Version.KernelVersion.XNU }} | {{ .Old.Kernel.Version.KernelVersion.Date.Format "Mon, 02Jan2006 15:04:05 MST" }} | | {{ .New.Version }} *({{ .New.Build }})* | {{ .New.Kernel.Version.KernelVersion.Darwin }} | {{ .New.Kernel.Version.KernelVersion.XNU }} | {{ .New.Kernel.Version.KernelVersion.Date.Format "Mon, 02Jan2006 15:04:05 MST" }} | {{ end -}} {{ if .Kexts }} ### Kexts {{ if .Kexts.New }} ### 🆕 NEW {{ range .Kexts.New }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Kexts.Removed }} ### ❌ Removed {{ range .Kexts.Removed }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Kexts.Updated }} ### ⬆️ Updated
View Updated {{ range $key, $value := .Kexts.Updated }} #### {{ $key | base }} > {{ $key | code }} {{ $value | noescape }} {{ end }}
{{ end -}} {{ end -}} {{ if .KDKs }} ## KDKs - {{ .Old.KDK | code}} - {{ .New.KDK | code}} {{ .KDKs | noescape }} {{ end -}} {{- if .Machos }} ## MachOs {{- range machoVolumes .Machos }} ### {{ .Name }} {{ if .Diff.New }} #### 🆕 NEW {{ range .Diff.New }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Diff.Removed }} #### ❌ Removed {{ range .Diff.Removed }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Diff.Updated }} #### ⬆️ Updated
View Updated {{ range $key, $value := .Diff.Updated }} ##### {{ $key | base }} > {{ $key | code }} {{ $value | noescape }} {{ end }}
{{ end -}} {{- end }} {{- end }} {{ if .Ents }} ### 🔑 Entitlements {{- range entsVolumes .Ents }} #### {{ .Name }}
View Entitlements {{ .Content | noescape }}
{{- end }} {{ end -}} {{ if .Sandbox }} ## Sandbox Profiles {{ .Sandbox | noescape }} {{ end -}} {{- if .Firmwares }} ## Firmwares {{ if .Firmwares.New }} ### 🆕 NEW {{ range .Firmwares.New }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Firmwares.Removed }} ### ❌ Removed {{ range .Firmwares.Removed }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Firmwares.Updated }} ### ⬆️ Updated
View Updated {{ range $key, $value := .Firmwares.Updated }} #### {{ $key | base }} > {{ $key | code }} {{ $value | noescape }} {{ end }}
{{ end -}} {{ end -}} {{ if .Launchd }} ## launchd Config {{ .Launchd | noescape }} {{ end }} {{ if .Localizations }} ## Localizations {{- range plistVolumes .Localizations }} ### {{ .Name }} {{ if .Diff.New }} #### 🆕 NEW {{ range $key, $value := .Diff.New }} ##### {{ $key | locbase }} > {{ $key | code }} ~~~text {{ $value }} ~~~ {{ end }} {{ end -}} {{- if .Diff.Removed }} #### ❌ Removed {{ range .Diff.Removed }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Diff.Updated }} #### ⬆️ Updated
View Updated {{ range $key, $value := .Diff.Updated }} ##### {{ $key | locbase }} > {{ $key | code }} {{ $value | noescape }} {{ end }}
{{ end -}} {{- end }} {{- end }} ## DSC {{ if .Old.Webkit }} ### WebKit | iOS | Version | | :-------------------------------------- | :---------------- | | {{ .Old.Version }} *({{ .Old.Build }})* | {{ .Old.Webkit }} | | {{ .New.Version }} *({{ .New.Build }})* | {{ .New.Webkit }} | {{ end -}} {{- if .Dylibs }} ### Dylibs {{ if .Dylibs.New }} #### 🆕 NEW {{ range .Dylibs.New }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Dylibs.Removed }} #### ❌ Removed {{ range .Dylibs.Removed }} - {{ . | code }} {{ end }} {{ end -}} {{- if .Dylibs.Updated }} #### ⬆️ Updated
View Updated {{ range $key, $value := .Dylibs.Updated }} ##### {{ $key | base }} > {{ $key | code }} {{ $value | noescape }} {{ end }}
{{ end -}} {{ end -}} ` var diffMDTmpl = template.Must(template.New("diff"). Funcs(template.FuncMap{ "noescape": func(value any) template.HTML { return template.HTML(fmt.Sprint(value)) }, "base": func(value string) template.HTML { return template.HTML(fmt.Sprintf("`%s`", filepath.Base(value))) }, "locbase": func(value string) template.HTML { return template.HTML(fmt.Sprintf("`%s`", localizationDisplayName(value))) }, "code": func(value string) template.HTML { return template.HTML(fmt.Sprintf("`%s`", value)) }, "slug": func(value string) string { return utils.Slugify(value) }, "machoVolumes": templateMachoVolumes, "plistVolumes": templatePlistVolumes, "entsVolumes": templateEntsVolumes, }). Parse(diffMarkdownTemplate)) // templateMachoVolumes returns the sorted-and-filtered per-volume MachOs // entries for use inside Markdown/HTML templates. It mirrors the slice that // the HTML page renderer pre-builds (convertMachoVolumeDiff) so both // renderers walk the same ordered structure. func templateMachoVolumes(m map[string]*mcmd.MachoDiff) []templateMachoVolume { if len(m) == 0 { return nil } out := make([]templateMachoVolume, 0, len(m)) for _, vol := range sortedVolumeKeys(m) { md := m[vol] if !machoDiffHasContent(md) { continue } out = append(out, templateMachoVolume{Name: vol, Diff: md}) } return out } type templateMachoVolume struct { Name string Diff *mcmd.MachoDiff } func templatePlistVolumes(m map[string]*PlistDiff) []templatePlistVolume { if len(m) == 0 { return nil } out := make([]templatePlistVolume, 0, len(m)) for _, vol := range sortedVolumeKeys(m) { pd := m[vol] if !plistDiffHasContent(pd) { continue } out = append(out, templatePlistVolume{Name: vol, Diff: pd}) } return out } type templatePlistVolume struct { Name string Diff *PlistDiff } func templateEntsVolumes(m map[string]string) []templateEntsVolume { if len(m) == 0 { return nil } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } keys = sortVolumeNames(keys) out := make([]templateEntsVolume, 0, len(keys)) for _, vol := range keys { rendered := m[vol] if !entitlementsDiffHasContent(rendered) { continue } out = append(out, templateEntsVolume{Name: vol, Content: rendered}) } return out } type templateEntsVolume struct { Name string Content string } // htmlTaskFuncs is the FuncMap shared between the outer page template and // the per-task sub-templates that render in-scope sections. var htmlTaskFuncs = template.FuncMap{ "dict": func(pairs ...any) map[string]any { m := make(map[string]any, len(pairs)/2) for i := 0; i < len(pairs)-1; i += 2 { m[pairs[i].(string)] = pairs[i+1] } return m }, "slug": utils.Slugify, } var diffHTMLTmpl = template.Must(template.New("diff-html-page"). Funcs(htmlTaskFuncs). Parse(diffHTMLPageTemplate)) // htmlTaskPartials defines the shared sub-templates that per-task HTML // renderers compose into. machoDiffSection and plistDiffSection are the // same blocks the outer page template uses, factored so per-task templates // can produce byte-identical output without duplicating their bodies. const htmlTaskPartials = ` {{ define "machoDiffSection" }} {{- if .Diff.New }}

New

{{- end }} {{- if .Diff.Removed }}

Removed

{{- end }} {{- if .Diff.Updated }}

Updated

View Updated ({{ len .Diff.Updated }}) {{- range .Diff.Updated }}
{{ .Name }}
{{ .Path }}
{{ .Diff }}
{{- end }}
{{- end }} {{ end }} {{ define "plistDiffSection" }} {{- if .Diff.New }}

New

View New ({{ len .Diff.New }}) {{- range .Diff.New }}
{{ .Name }}
{{ .Path }}
{{ .Content }}
{{- end }}
{{- end }} {{- if .Diff.Removed }}

Removed

{{- end }} {{- if .Diff.Updated }}

Updated

View Updated ({{ len .Diff.Updated }}) {{- range .Diff.Updated }}
{{ .Name }}
{{ .Path }}
{{ .Content }}
{{- end }}
{{- end }} {{ end }} ` // newHTMLTaskTemplate parses body in a new template that also has the // shared partials registered, so per-task renderers can call // {{ template "machoDiffSection" ... }} and {{ template "plistDiffSection" ... }}. func newHTMLTaskTemplate(name, body string) (*template.Template, error) { tmpl := template.New(name).Funcs(htmlTaskFuncs) if _, err := tmpl.Parse(htmlTaskPartials); err != nil { return nil, err } if _, err := tmpl.Parse(body); err != nil { return nil, err } return tmpl, nil } // executeHTMLTaskTemplate renders body (after composing it with the shared // partials) against data and returns the result as template.HTML. func executeHTMLTaskTemplate(name, body string, data any) (template.HTML, error) { tmpl, err := newHTMLTaskTemplate(name, body) if err != nil { return "", err } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", err } return template.HTML(buf.String()), nil } func (d *Diff) String() string { var buf bytes.Buffer if err := diffMDTmpl.Execute(&buf, d); err != nil { return fmt.Errorf("failed to execute diff template: %s", err).Error() } return buf.String() } // ToJSON saves the diff as a JSON file func (d *Diff) ToJSON() error { report, err := buildReport(d) if err != nil { return err } dat, err := json.MarshalIndent(report, "", " ") if err != nil { return err } if len(d.conf.Output) > 0 { if err := os.MkdirAll(d.conf.Output, 0755); err != nil { return err } fname := filepath.Join(d.conf.Output, d.TitleToFilename()+".json") log.Infof("Creating JSON diff file: %s", fname) return os.WriteFile(fname, dat, 0644) } fmt.Println(string(dat)) return nil } type diffHTMLPageData struct { Title string OldInput string NewInput string OldVersion string OldBuild string NewVersion string NewBuild string HasKernelVersion bool OldKernelDarwin string OldKernelXNU string OldKernelDate string NewKernelDarwin string NewKernelXNU string NewKernelDate string // TOC-only converted views: every in-scope task (mount-based or // top-level) keeps its sidebar TOC entry while the section bodies // themselves render via the per-task HTML fragments below. KDKs and // Sandbox are kept as plain template.HTML / string so the TOC's // `{{- if .KDKs }}` / `{{- if .Sandbox }}` truthiness checks keep // working without re-marshalling task fragments. Kexts *htmlMachoDiff KDKs template.HTML Sandbox template.HTML Firmwares *htmlMachoDiff IBoot *htmlIBootDiff Machos []htmlMachoVolume Ents []htmlEntsVolume Features []htmlPlistVolume Files *htmlFileDiff Localizations []htmlPlistVolume // Per-task body fragments for both the mount-based (kexts/kdks/...) // and top-level (kexts/kdks/firmwares/iboot/sandbox) sections. Each // is produced by calling the owning task's HTML() method; if Empty() // is true the section emits no heading or body. KextsFragment htmlSectionFragment KDKsFragment htmlSectionFragment MachosFragment htmlSectionFragment EntsFragment htmlSectionFragment FirmwaresFragment htmlSectionFragment IBootFragment htmlSectionFragment LaunchdFragment htmlSectionFragment SandboxFragment htmlSectionFragment FilesFragment htmlSectionFragment LocalizationsFragment htmlSectionFragment FeaturesFragment htmlSectionFragment OldWebkit string NewWebkit string Dylibs *htmlMachoDiff } // htmlSectionFragment is the outer-template view of a per-task HTML fragment. // renderHTML builds one of these per in-scope task by combining the task's // HTMLFragment (Heading + Body) with the section's stable anchor ID. type htmlSectionFragment struct { Anchor string Heading string Body template.HTML Empty bool } type htmlMachoDiff struct { New []string Removed []string Updated []htmlUpdatedEntry } // htmlMachoVolume groups a single volume's MachoDiff for per-volume HTML // rendering. Used by the d.Machos shape (cross-DMG). type htmlMachoVolume struct { Name string Diff *htmlMachoDiff } type htmlUpdatedEntry struct { Name string Path string Diff template.HTML } type htmlNamedList struct { Name string Items []string } type htmlIBootDiff struct { Versions []string New []htmlNamedList Removed []htmlNamedList } type htmlPlistEntry struct { Name string Path string Content template.HTML } type htmlPlistDiff struct { New []htmlPlistEntry Removed []string Updated []htmlPlistEntry } // htmlPlistVolume groups a single volume's PlistDiff for per-volume HTML // rendering (used by d.Features and d.Localizations). type htmlPlistVolume struct { Name string Diff *htmlPlistDiff } // htmlEntsVolume groups a single volume's rendered entitlements diff for // per-volume HTML rendering. type htmlEntsVolume struct { Name string Content template.HTML } func convertEntitlementsDiff(m map[string]string) []htmlEntsVolume { if len(m) == 0 { return nil } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } keys = sortVolumeNames(keys) out := make([]htmlEntsVolume, 0, len(keys)) for _, vol := range keys { rendered := m[vol] if !entitlementsDiffHasContent(rendered) { continue } out = append(out, htmlEntsVolume{ Name: vol, Content: renderMarkdownFragment(rendered), }) } return out } type htmlFileDiff struct { New []htmlNamedList Removed []htmlNamedList } const diffHTMLPageTemplate = ` {{ .Title }}
ipsw Diff Report

{{ .Title }}

Old Input {{ .OldInput }} {{- if .OldVersion }}{{ .OldVersion }} · {{ .OldBuild }}{{ end }}
New Input {{ .NewInput }} {{- if .NewVersion }}{{ .NewVersion }} · {{ .NewBuild }}{{ end }}

Inputs

  • {{ .OldInput }}
  • {{ .NewInput }}

Kernel

{{- if .HasKernelVersion }}

Version

Kernel version comparison across the old and new inputs.
iOSVersionBuildDate
{{ .OldVersion }} ({{ .OldBuild }}){{ .OldKernelDarwin }}{{ .OldKernelXNU }}{{ .OldKernelDate }}
{{ .NewVersion }} ({{ .NewBuild }}){{ .NewKernelDarwin }}{{ .NewKernelXNU }}{{ .NewKernelDate }}
{{- end }} {{- if not .KextsFragment.Empty }}{{ .KextsFragment.Body }}{{- end }} {{- if not .KDKsFragment.Empty }}{{ .KDKsFragment.Body }}{{- end }} {{- if not .MachosFragment.Empty }}

{{ .MachosFragment.Heading }}

{{ .MachosFragment.Body }} {{- end }} {{- if not .EntsFragment.Empty }}

{{ .EntsFragment.Heading }}

{{ .EntsFragment.Body }} {{- end }} {{- if not .FirmwaresFragment.Empty }}{{ .FirmwaresFragment.Body }}{{- end }} {{- if not .IBootFragment.Empty }}{{ .IBootFragment.Body }}{{- end }} {{- if not .LaunchdFragment.Empty }}

{{ .LaunchdFragment.Heading }}

{{ .LaunchdFragment.Body }} {{- end }} {{- if not .SandboxFragment.Empty }}{{ .SandboxFragment.Body }}{{- end }}

DSC

{{- if .OldWebkit }}

WebKit

WebKit version comparison across the old and new inputs.
iOSVersion
{{ .OldVersion }} ({{ .OldBuild }}){{ .OldWebkit }}
{{ .NewVersion }} ({{ .NewBuild }}){{ .NewWebkit }}
{{- end }} {{- if .Dylibs }}

Dylibs

{{- template "machoDiffSection" dict "Prefix" "dylibs" "Diff" .Dylibs }} {{- end }} {{- if not .FilesFragment.Empty }}

{{ .FilesFragment.Heading }}

{{ .FilesFragment.Body }} {{- end }} {{- if not .LocalizationsFragment.Empty }}

{{ .LocalizationsFragment.Heading }}

{{ .LocalizationsFragment.Body }} {{- end }} {{- if not .FeaturesFragment.Empty }}

{{ .FeaturesFragment.Heading }}

{{ .FeaturesFragment.Body }} {{- end }}
{{ define "machoDiffSection" }} {{- if .Diff.New }}

New

{{- end }} {{- if .Diff.Removed }}

Removed

{{- end }} {{- if .Diff.Updated }}

Updated

View Updated ({{ len .Diff.Updated }}) {{- range .Diff.Updated }}
{{ .Name }}
{{ .Path }}
{{ .Diff }}
{{- end }}
{{- end }} {{ end }} {{ define "plistDiffSection" }} {{- if .Diff.New }}

New

View New ({{ len .Diff.New }}) {{- range .Diff.New }}
{{ .Name }}
{{ .Path }}
{{ .Content }}
{{- end }}
{{- end }} {{- if .Diff.Removed }}

Removed

{{- end }} {{- if .Diff.Updated }}

Updated

View Updated ({{ len .Diff.Updated }}) {{- range .Diff.Updated }}
{{ .Name }}
{{ .Path }}
{{ .Content }}
{{- end }}
{{- end }} {{ end }} ` func renderCodeBlock(raw string) template.HTML { if raw == "" { return "" } var buf strings.Builder buf.WriteString("
")
	buf.WriteString(html.EscapeString(raw))
	buf.WriteString("
") return template.HTML(buf.String()) } func stripDiffFence(raw string) string { trimmed := strings.TrimSpace(raw) if !strings.HasPrefix(trimmed, "```diff") { return raw } body := strings.TrimPrefix(trimmed, "```diff") body = strings.TrimPrefix(body, "\r\n") body = strings.TrimPrefix(body, "\n") if idx := strings.LastIndex(body, "\n```"); idx >= 0 { return body[:idx] } if before, ok := strings.CutSuffix(body, "```"); ok { return before } return raw } func renderMarkdownChunk(md string) string { if strings.TrimSpace(md) == "" { return "" } renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{ Flags: mdhtml.CommonFlags, }) return string(markdown.ToHTML([]byte(md), nil, renderer)) } func highlightDiff(raw string) template.HTML { var buf strings.Builder first := true for line := range strings.SplitSeq(raw, "\n") { if !first { buf.WriteByte('\n') } first = false escaped := html.EscapeString(line) switch { case strings.HasPrefix(line, "+"): buf.WriteString(``) buf.WriteString(escaped) buf.WriteString("") case strings.HasPrefix(line, "-"): buf.WriteString(``) buf.WriteString(escaped) buf.WriteString("") case strings.HasPrefix(line, "~"): buf.WriteString(``) buf.WriteString(escaped) buf.WriteString("") default: buf.WriteString(escaped) } } return template.HTML(buf.String()) } func renderHighlightedDiffBlock(raw string) string { if raw == "" { return "" } var buf strings.Builder buf.WriteString("
")
	buf.WriteString(string(highlightDiff(raw)))
	buf.WriteString("
") return buf.String() } func renderMarkdownFragment(md string) template.HTML { if md == "" { return "" } var out strings.Builder var markdownBuf strings.Builder var diffBuf strings.Builder inDiffFence := false flushMarkdown := func() { if markdownBuf.Len() == 0 { return } out.WriteString(renderMarkdownChunk(markdownBuf.String())) markdownBuf.Reset() } flushDiff := func() { if diffBuf.Len() == 0 { return } out.WriteString(renderHighlightedDiffBlock(diffBuf.String())) diffBuf.Reset() } lines := strings.Split(md, "\n") for idx, line := range lines { trimmed := strings.TrimSpace(line) if inDiffFence { if trimmed == "```" { flushDiff() inDiffFence = false continue } if diffBuf.Len() > 0 { diffBuf.WriteByte('\n') } diffBuf.WriteString(line) continue } if trimmed == "```diff" { flushMarkdown() inDiffFence = true continue } markdownBuf.WriteString(line) if idx < len(lines)-1 { markdownBuf.WriteByte('\n') } } if inDiffFence { markdownBuf.WriteString("```diff\n") markdownBuf.WriteString(diffBuf.String()) } flushMarkdown() return template.HTML(out.String()) } func convertMachoDiff(md *mcmd.MachoDiff) *htmlMachoDiff { if md == nil { return nil } h := &htmlMachoDiff{ New: md.New, Removed: md.Removed, } for _, k := range slices.Sorted(maps.Keys(md.Updated)) { h.Updated = append(h.Updated, htmlUpdatedEntry{ Name: filepath.Base(k), Path: k, Diff: highlightDiff(stripDiffFence(md.Updated[k])), }) } return h } // convertPlistVolumeDiff turns a per-volume PlistDiff map into an ordered // slice the HTML template iterates. Empty volumes are omitted. nameForPath // chooses the display name used for entry headings. func convertPlistVolumeDiff(m map[string]*PlistDiff, nameForPath func(string) string) []htmlPlistVolume { if len(m) == 0 { return nil } out := make([]htmlPlistVolume, 0, len(m)) for _, vol := range sortedVolumeKeys(m) { pd := m[vol] if !plistDiffHasContent(pd) { continue } out = append(out, htmlPlistVolume{ Name: vol, Diff: convertPlistDiffWithName(pd, nameForPath), }) } return out } // convertMachoVolumeDiff turns the per-volume d.Machos map into an ordered // slice the HTML template iterates. Empty volumes are omitted; volumes are // emitted in canonical order (filesystem, SystemOS, AppOS, ExclaveOS) with // any others trailing alphabetically. func convertMachoVolumeDiff(m map[string]*mcmd.MachoDiff) []htmlMachoVolume { if len(m) == 0 { return nil } out := make([]htmlMachoVolume, 0, len(m)) for _, vol := range sortedVolumeKeys(m) { md := m[vol] if !machoDiffHasContent(md) { continue } out = append(out, htmlMachoVolume{ Name: vol, Diff: convertMachoDiff(md), }) } return out } func sortedNamedLists(m map[string][]string) []htmlNamedList { var out []htmlNamedList for _, k := range slices.Sorted(maps.Keys(m)) { out = append(out, htmlNamedList{ Name: k, Items: slices.Sorted(slices.Values(m[k])), }) } return out } func convertIBootDiff(ib *IBootDiff) *htmlIBootDiff { if ib == nil { return nil } out := &htmlIBootDiff{ Versions: append([]string(nil), ib.Versions...), New: sortedNamedLists(ib.New), Removed: sortedNamedLists(ib.Removed), } if len(out.Versions) == 0 && len(out.New) == 0 && len(out.Removed) == 0 { return nil } return out } func convertPlistDiffWithName(pd *PlistDiff, nameForPath func(string) string) *htmlPlistDiff { if pd == nil { return nil } out := &htmlPlistDiff{ Removed: slices.Sorted(slices.Values(pd.Removed)), } for _, k := range slices.Sorted(maps.Keys(pd.New)) { out.New = append(out.New, htmlPlistEntry{ Name: nameForPath(k), Path: k, Content: renderCodeBlock(pd.New[k]), }) } for _, k := range slices.Sorted(maps.Keys(pd.Updated)) { out.Updated = append(out.Updated, htmlPlistEntry{ Name: nameForPath(k), Path: k, Content: renderMarkdownFragment(pd.Updated[k]), }) } if len(out.New) == 0 && len(out.Removed) == 0 && len(out.Updated) == 0 { return nil } return out } func convertFileDiff(fd *FileDiff) *htmlFileDiff { if fd == nil { return nil } order := []string{"IPSW", "filesystem", "SystemOS", "AppOS", "ExclaveOS"} out := &htmlFileDiff{} for _, name := range order { if items, ok := fd.New[name]; ok && len(items) > 0 { out.New = append(out.New, htmlNamedList{Name: name, Items: slices.Sorted(slices.Values(items))}) } } for _, name := range order { if items, ok := fd.Removed[name]; ok && len(items) > 0 { out.Removed = append(out.Removed, htmlNamedList{Name: name, Items: slices.Sorted(slices.Values(items))}) } } if len(out.New) == 0 && len(out.Removed) == 0 { return nil } return out } // htmlSectionAnchors maps each in-scope renderer's JSONKey() to the stable // section anchor ID the outer template embeds in

. These IDs // must not change without coordinating with downstream link consumers. // // Top-level task fragments (kexts, kdks, firmwares, iboot, sandbox) // embed their own `` heading inside Body, so they do not // participate in the outer-template anchor wrapping handled by this // map; they still appear here as documentation of the canonical // section ID each fragment owns. var htmlSectionAnchors = map[string]string{ "kexts": "kexts", "kdks": "kdks", "machos": "machos", "ents": "entitlements", "firmwares": "firmwares", "iboot": "iboot", "launchd": "launchd", "sandbox": "sandbox-profiles", "files": "files", "localizations": "localizations", "features": "feature-flags", } // buildHTMLSectionFragment calls r.HTML(), then wraps the result in the // outer-template view that the page renderer expects. Empty tasks produce // a fragment with Empty=true so the outer template renders nothing. func buildHTMLSectionFragment(r interface { JSONKey() string Empty() bool HTML() (HTMLFragment, error) }) (htmlSectionFragment, error) { anchor, ok := htmlSectionAnchors[r.JSONKey()] if !ok { return htmlSectionFragment{}, fmt.Errorf("no html anchor registered for task %q", r.JSONKey()) } if r.Empty() { return htmlSectionFragment{Anchor: anchor, Empty: true}, nil } frag, err := r.HTML() if err != nil { return htmlSectionFragment{}, err } return htmlSectionFragment{ Anchor: anchor, Heading: frag.Heading, Body: frag.Body, }, nil } func (d *Diff) renderHTML() (string, error) { var htmlBuf bytes.Buffer // Top-level tasks (kexts/kdks/firmwares/iboot/sandbox) and mount-based // renderers (machos/ents/launchd/files/localizations/features) all // expose HTML()/Empty(); build one HTML fragment per task in source order. kexts := newKextsTask(d) kdks := newKDKsTask(d) machos := newMachosRenderer(d.Machos) ents := newEntsRenderer(d.Ents) firmwares := newFirmwaresTask(d) iboot := newIBootTask(d) launchd := newLaunchdRenderer(d.Launchd) sandbox := newSandboxTask(d) files := newFilesRenderer(d.Files) locs := newLocsRenderer(d.Localizations) features := newFeaturesRenderer(d.Features) kextsFrag, err := buildHTMLSectionFragment(kexts) if err != nil { return "", err } kdksFrag, err := buildHTMLSectionFragment(kdks) if err != nil { return "", err } machosFrag, err := buildHTMLSectionFragment(machos) if err != nil { return "", err } entsFrag, err := buildHTMLSectionFragment(ents) if err != nil { return "", err } firmwaresFrag, err := buildHTMLSectionFragment(firmwares) if err != nil { return "", err } ibootFrag, err := buildHTMLSectionFragment(iboot) if err != nil { return "", err } launchdFrag, err := buildHTMLSectionFragment(launchd) if err != nil { return "", err } sandboxFrag, err := buildHTMLSectionFragment(sandbox) if err != nil { return "", err } filesFrag, err := buildHTMLSectionFragment(files) if err != nil { return "", err } locsFrag, err := buildHTMLSectionFragment(locs) if err != nil { return "", err } featuresFrag, err := buildHTMLSectionFragment(features) if err != nil { return "", err } data := diffHTMLPageData{ Title: d.Title, OldInput: filepath.Base(d.Old.IPSWPath), NewInput: filepath.Base(d.New.IPSWPath), OldVersion: d.Old.Version, OldBuild: d.Old.Build, NewVersion: d.New.Version, NewBuild: d.New.Build, OldWebkit: d.Old.Webkit, NewWebkit: d.New.Webkit, Kexts: convertMachoDiff(d.Kexts), Machos: convertMachoVolumeDiff(d.Machos), Dylibs: convertMachoDiff(d.Dylibs), Firmwares: convertMachoDiff(d.Firmwares), IBoot: convertIBootDiff(d.IBoot), Features: convertPlistVolumeDiff(d.Features, filepath.Base), Files: convertFileDiff(d.Files), Localizations: convertPlistVolumeDiff(d.Localizations, localizationDisplayName), Ents: convertEntitlementsDiff(d.Ents), KDKs: renderMarkdownFragment(d.KDKs), Sandbox: renderMarkdownFragment(d.Sandbox), KextsFragment: kextsFrag, KDKsFragment: kdksFrag, MachosFragment: machosFrag, EntsFragment: entsFrag, FirmwaresFragment: firmwaresFrag, IBootFragment: ibootFrag, LaunchdFragment: launchdFrag, SandboxFragment: sandboxFrag, FilesFragment: filesFrag, LocalizationsFragment: locsFrag, FeaturesFragment: featuresFrag, } if d.Old.Kernel.Version != nil { data.HasKernelVersion = true data.OldKernelDarwin = d.Old.Kernel.Version.KernelVersion.Darwin data.OldKernelXNU = d.Old.Kernel.Version.KernelVersion.XNU data.OldKernelDate = d.Old.Kernel.Version.KernelVersion.Date.Format( "Mon, 02 Jan 2006 15:04:05 MST", ) } if d.New.Kernel.Version != nil { data.NewKernelDarwin = d.New.Kernel.Version.KernelVersion.Darwin data.NewKernelXNU = d.New.Kernel.Version.KernelVersion.XNU data.NewKernelDate = d.New.Kernel.Version.KernelVersion.Date.Format( "Mon, 02 Jan 2006 15:04:05 MST", ) } if err := diffHTMLTmpl.Execute(&htmlBuf, data); err != nil { return "", err } return htmlBuf.String(), nil } func (d *Diff) ToHTML() error { rendered, err := d.renderHTML() if err != nil { return err } if len(d.conf.Output) == 0 { fmt.Println(rendered) return nil } if err := os.MkdirAll(d.conf.Output, 0755); err != nil { return err } fname := filepath.Join(d.conf.Output, d.TitleToFilename()+".html") log.Infof("Creating HTML diff file: %s", fname) return os.WriteFile(fname, []byte(rendered), 0644) }