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
{{ range .Diff.New }}{{ . }} {{ end }}
{{- end }}
{{- if .Diff.Removed }}
Removed
{{ range .Diff.Removed }}{{ . }} {{ end }}
{{- 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
{{ range .Diff.Removed }}{{ . }} {{ end }}
{{- 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 }}
{{ .OldInput }}
{{ .NewInput }}
Kernel
{{- if .HasKernelVersion }}
Version
Kernel version comparison across the old and new inputs.
| iOS | Version | Build | Date |
| {{ .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.
| iOS | Version |
| {{ .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
{{ range .Diff.New }}{{ . }} {{ end }}
{{- end }}
{{- if .Diff.Removed }}
Removed
{{ range .Diff.Removed }}{{ . }} {{ end }}
{{- 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
{{ range .Diff.Removed }}{{ . }} {{ end }}
{{- 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)
}