Files
ipsw/internal/diff/format.go
blacktop e209828c31 feat(diff): add cacheable task-based reports
Refactor diff jobs into task-owned renderers with persistent cache support, root-confined walkers, and quieter Mach-O/Firmware output.
2026-06-04 12:02:46 -06:00

1716 lines
53 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
<details>
<summary><i>View Updated</i></summary>
{{ range $key, $value := .Kexts.Updated }}
#### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}
</details>
{{ 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
<details>
<summary><i>View Updated</i></summary>
{{ range $key, $value := .Diff.Updated }}
##### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}
</details>
{{ end -}}
{{- end }}
{{- end }}
{{ if .Ents }}
### 🔑 Entitlements
{{- range entsVolumes .Ents }}
#### {{ .Name }}
<details>
<summary><i>View Entitlements</i></summary>
{{ .Content | noescape }}
</details>
{{- 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
<details>
<summary><i>View Updated</i></summary>
{{ range $key, $value := .Firmwares.Updated }}
#### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}
</details>
{{ 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
<details>
<summary><i>View Updated</i></summary>
{{ range $key, $value := .Diff.Updated }}
##### {{ $key | locbase }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}
</details>
{{ 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
<details>
<summary><i>View Updated</i></summary>
{{ range $key, $value := .Dylibs.Updated }}
##### {{ $key | base }}
> {{ $key | code }}
{{ $value | noescape }}
{{ end }}
</details>
{{ 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 }}
<h4 id="{{ .Prefix }}-new">New</h4>
<ul>{{ range .Diff.New }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
<summary>View Updated ({{ len .Diff.Updated }})</summary>
{{- range .Diff.Updated }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
<pre><code>{{ .Diff }}</code></pre>
</div>
{{- end }}
</details>
{{- end }}
{{ end }}
{{ define "plistDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<details>
<summary>View New ({{ len .Diff.New }})</summary>
{{- range .Diff.New }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
{{ .Content }}
</div>
{{- end }}
</details>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
<summary>View Updated ({{ len .Diff.Updated }})</summary>
{{- range .Diff.Updated }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
{{ .Content }}
</div>
{{- end }}
</details>
{{- 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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>{{ .Title }}</title>
<style>
:root {
--radius: 18px;
--background: oklch(0.985 0.004 247.858);
--foreground: oklch(0.205 0.01 257.417);
--card: oklch(0.999 0 0);
--card-foreground: oklch(0.205 0.01 257.417);
--muted: oklch(0.968 0.006 247.896);
--muted-foreground: oklch(0.473 0.013 255.508);
--primary: oklch(0.598 0.112 221.723);
--primary-foreground: oklch(0.985 0 0);
--accent: oklch(0.929 0.03 214.91);
--accent-foreground: oklch(0.278 0.033 256.848);
--border: oklch(0.923 0.01 252.096);
--shadow-soft: 0 24px 60px -32px oklch(0.188 0.012 257.417 / 0.28);
--shadow-panel: 0 18px 40px -28px oklch(0.188 0.012 257.417 / 0.22);
--code-bg: oklch(0.25 0.014 253.1);
--code-fg: oklch(0.96 0.004 247.858);
--diff-add: oklch(0.30 0.10 145);
--diff-add-bg: oklch(0.96 0.03 145);
--diff-del: oklch(0.38 0.10 25);
--diff-del-bg: oklch(0.96 0.03 25);
--diff-mod: oklch(0.40 0.10 75);
--diff-mod-bg: oklch(0.96 0.03 75);
--hero-wash: radial-gradient(circle at top left, oklch(0.96 0.04 215 / 0.9), transparent 38%),
radial-gradient(circle at top right, oklch(0.92 0.025 250 / 0.75), transparent 34%),
linear-gradient(135deg, oklch(0.995 0.002 247.858), oklch(0.975 0.01 247.896));
}
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.14 0.012 257.417);
--foreground: oklch(0.96 0.004 247.858);
--card: oklch(0.19 0.014 255.508);
--card-foreground: oklch(0.96 0.004 247.858);
--muted: oklch(0.24 0.012 255.508);
--muted-foreground: oklch(0.7 0.013 255.508);
--primary: oklch(0.72 0.104 221.723);
--primary-foreground: oklch(0.205 0.01 257.417);
--accent: oklch(0.28 0.02 224.12);
--accent-foreground: oklch(0.96 0.004 247.858);
--border: oklch(0.30 0.012 255.508);
--shadow-soft: 0 30px 80px -40px oklch(0.02 0 0 / 0.65);
--shadow-panel: 0 22px 46px -32px oklch(0.02 0 0 / 0.56);
--code-bg: oklch(0.15 0.01 255.508);
--code-fg: oklch(0.92 0.004 247.858);
--diff-add: oklch(0.72 0.16 145);
--diff-add-bg: oklch(0.22 0.04 145);
--diff-del: oklch(0.72 0.16 25);
--diff-del-bg: oklch(0.22 0.04 25);
--diff-mod: oklch(0.76 0.12 75);
--diff-mod-bg: oklch(0.22 0.03 75);
--hero-wash: radial-gradient(circle at top left, oklch(0.26 0.036 215 / 0.55), transparent 38%),
radial-gradient(circle at top right, oklch(0.22 0.02 250 / 0.5), transparent 34%),
linear-gradient(135deg, oklch(0.18 0.012 257.417), oklch(0.15 0.012 255.508));
}
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--foreground);
background:
radial-gradient(circle at top, oklch(0.9 0.02 215 / 0.18), transparent 30%),
linear-gradient(180deg, color-mix(in oklab, var(--background) 92%, white 8%), var(--background));
font-family: "Avenir Next", "Aptos", "Segoe UI", sans-serif;
line-height: 1.65;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image: linear-gradient(to right, color-mix(in oklab, var(--border) 40%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklab, var(--border) 35%, transparent) 1px, transparent 1px);
background-size: 32px 32px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%);
opacity: 0.28;
}
a { color: color-mix(in oklab, var(--primary) 82%, var(--foreground) 18%); text-decoration: none; }
a:hover { color: var(--primary); }
code, pre, kbd { font-family: "IBM Plex Mono", "SFMono-Regular", "SF Mono", Consolas, monospace; }
.report-shell { position: relative; max-width: 1500px; margin: 0 auto; padding: 28px 22px 72px; }
.panel {
background: color-mix(in oklab, var(--card) 94%, transparent 6%);
color: var(--card-foreground);
border: 1px solid color-mix(in oklab, var(--border) 90%, transparent 10%);
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(16px);
}
.report-hero { position: relative; overflow: hidden; margin-bottom: 24px; padding: 28px; background: var(--hero-wash); }
.report-hero::after {
content: "";
position: absolute;
inset: auto -10% -35% auto;
width: 320px; height: 320px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, var(--primary) 32%, transparent 68%) 0%, transparent 72%);
opacity: 0.9;
pointer-events: none;
}
.hero-topline {
display: inline-flex; align-items: center; gap: 10px;
margin-bottom: 16px; padding: 8px 12px;
border-radius: 999px;
background: color-mix(in oklab, var(--card) 80%, transparent 20%);
border: 1px solid color-mix(in oklab, var(--border) 80%, transparent 20%);
color: var(--muted-foreground);
font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase;
}
.hero-grid {
position: relative; z-index: 1;
display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr);
gap: 18px; align-items: start;
}
.hero-title {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
font-size: clamp(2.2rem, 4vw, 4rem);
line-height: 1.02; letter-spacing: -0.04em;
text-wrap: balance;
}
.hero-subtitle { max-width: 62ch; margin: 14px 0 4px; color: var(--muted-foreground); font-size: 1rem; }
.hero-meta { display: grid; gap: 12px; }
.meta-card {
padding: 16px 18px; border-radius: calc(var(--radius) - 4px);
background: color-mix(in oklab, var(--card) 82%, transparent 18%);
border: 1px solid color-mix(in oklab, var(--border) 76%, transparent 24%);
box-shadow: var(--shadow-soft);
}
.meta-label { display: block; margin-bottom: 6px; color: var(--muted-foreground); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.meta-value { display: block; font-size: 1rem; font-weight: 700; word-break: break-word; }
.meta-note { display: block; margin-top: 6px; color: var(--muted-foreground); font-size: 0.92rem; }
.report-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 24px; align-items: start; }
.report-layout.no-sidebar { grid-template-columns: minmax(0, 1fr); }
.report-sidebar { position: sticky; top: 22px; padding: 18px; }
.sidebar-title { margin: 0 0 14px; font-size: 0.8rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-foreground); }
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc ul ul { margin-top: 6px; margin-left: 10px; padding-left: 10px; border-left: 1px solid color-mix(in oklab, var(--border) 82%, transparent 18%); }
.toc li { margin: 2px 0; }
.toc a {
display: block; padding: 8px 10px; border-radius: 12px;
color: var(--muted-foreground); font-size: 0.92rem; line-height: 1.35;
transition: background-color 120ms ease, color 120ms ease, transform 120ms ease;
}
.toc a:hover { background: color-mix(in oklab, var(--accent) 70%, transparent 30%); color: var(--foreground); transform: translateX(2px); }
.report-main { min-width: 0; padding: 30px 32px; }
.rc h2, .rc h3, .rc h4, .rc h5 { scroll-margin-top: 28px; }
.rc h2 {
margin: 2.5rem 0 1rem; padding-top: 1.4rem;
border-top: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%);
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
font-size: clamp(1.55rem, 2vw, 2rem); line-height: 1.15; letter-spacing: -0.03em;
text-wrap: balance;
}
.rc h2:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
.rc h3, .rc h4, .rc h5 {
margin: 1.6rem 0 0.7rem; font-size: 0.82rem; font-weight: 800;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted-foreground);
}
.rc p, .rc li, .rc blockquote, .rc summary { font-size: 0.98rem; }
.rc p { max-width: 74ch; }
.rc ul, .rc ol { padding-left: 1.35rem; }
.rc li + li { margin-top: 0.28rem; }
.rc li::marker { color: color-mix(in oklab, var(--primary) 75%, var(--foreground) 25%); }
.rc table {
width: 100%; display: block; overflow-x: auto;
margin: 1.2rem 0 1.6rem; border-collapse: separate; border-spacing: 0;
border: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%);
border-radius: calc(var(--radius) - 6px);
background: color-mix(in oklab, var(--card) 92%, transparent 8%);
}
.rc thead th {
background: color-mix(in oklab, var(--muted) 84%, transparent 16%);
color: var(--foreground); font-size: 0.82rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
}
.rc caption {
caption-side: bottom;
padding: 12px 14px 0;
color: var(--muted-foreground);
font-size: 0.9rem;
text-align: left;
}
.rc th, .rc td { padding: 12px 14px; border-bottom: 1px solid color-mix(in oklab, var(--border) 82%, transparent 18%); text-align: left; white-space: nowrap; }
.rc tr:last-child td { border-bottom: 0; }
.rc tbody tr:nth-child(even) { background: color-mix(in oklab, var(--muted) 48%, transparent 52%); }
.rc blockquote {
margin: 1.1rem 0; padding: 0.9rem 1rem;
border-left: 3px solid color-mix(in oklab, var(--primary) 68%, transparent 32%);
border-radius: 0 14px 14px 0;
background: color-mix(in oklab, var(--accent) 52%, transparent 48%);
color: var(--foreground);
}
.rc code {
padding: 0.18rem 0.44rem; border-radius: 0.55rem;
background: color-mix(in oklab, var(--muted) 72%, transparent 28%);
color: var(--foreground); font-size: 0.88em;
}
.rc pre {
overflow-x: auto; margin: 1rem 0 1.4rem; padding: 16px 18px;
border: 1px solid color-mix(in oklab, var(--border) 60%, transparent 40%);
border-radius: calc(var(--radius) - 4px);
background: linear-gradient(180deg, color-mix(in oklab, var(--code-bg) 88%, black 12%), var(--code-bg));
color: var(--code-fg);
box-shadow: inset 0 1px 0 color-mix(in oklab, white 8%, transparent 92%);
}
.rc pre code { padding: 0; background: transparent; color: inherit; border-radius: 0; font-size: 0.9rem; }
.rc .diff-add { color: var(--diff-add); background: var(--diff-add-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }
.rc .diff-del { color: var(--diff-del); background: var(--diff-del-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }
.rc .diff-mod { color: var(--diff-mod); background: var(--diff-mod-bg); display: inline-block; width: 100%; padding: 0 4px; margin: 0 -4px; border-radius: 3px; }
.rc details {
margin: 1.1rem 0 1.5rem; padding: 0.45rem 1rem 1rem;
border: 1px solid color-mix(in oklab, var(--border) 85%, transparent 15%);
border-radius: calc(var(--radius) - 4px);
background: color-mix(in oklab, var(--card) 90%, transparent 10%);
}
.rc details[open] { box-shadow: var(--shadow-soft); }
.rc summary {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
cursor: pointer; list-style: none; font-weight: 700; color: var(--foreground); padding: 0.6rem 0;
transition: color 150ms ease;
}
.rc summary:hover { color: var(--primary); }
.rc summary::-webkit-details-marker { display: none; }
.rc summary::after {
content: "+"; display: inline-flex; align-items: center; justify-content: center;
width: 1.6rem; height: 1.6rem; border-radius: 999px;
background: color-mix(in oklab, var(--accent) 72%, transparent 28%);
color: var(--accent-foreground); font-size: 1rem; font-weight: 700; flex-shrink: 0;
transition: transform 200ms ease, background-color 150ms ease;
}
.rc summary:hover::after { background: color-mix(in oklab, var(--primary) 30%, var(--accent) 70%); }
.rc details[open] summary::after { content: "\2212"; transform: rotate(180deg); }
.rc .diff-entry { margin: 1.5rem 0; padding-top: 1rem; border-top: 1px solid color-mix(in oklab, var(--border) 60%, transparent 40%); }
.rc .diff-entry:first-child { border-top: 0; padding-top: 0; margin-top: 0.5rem; }
.rc .diff-entry-path {
display: block; margin: 0.3rem 0 0.6rem; padding: 0.5rem 0.8rem;
border-left: 3px solid color-mix(in oklab, var(--primary) 68%, transparent 32%);
border-radius: 0 10px 10px 0;
background: color-mix(in oklab, var(--accent) 40%, transparent 60%);
color: var(--muted-foreground); font-size: 0.85rem;
}
.rc .diff-entry-path code { background: transparent; padding: 0; font-size: 0.82rem; }
.rc hr { margin: 2rem 0; border: 0; border-top: 1px solid color-mix(in oklab, var(--border) 88%, transparent 12%); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 6px; }
.toc a.active { background: color-mix(in oklab, var(--primary) 15%, transparent 85%); color: var(--foreground); font-weight: 600; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
@media (max-width: 1100px) {
.hero-grid, .report-layout, .report-layout.no-sidebar { grid-template-columns: minmax(0, 1fr); }
.report-sidebar { position: static; }
.report-main { padding: 24px 22px; }
}
@media (max-width: 700px) {
.report-shell { padding: 18px 14px 40px; }
.report-hero { padding: 22px 18px; }
.hero-title { font-size: 2rem; }
.report-main { padding: 20px 16px; }
}
</style>
</head>
<body>
<div class="report-shell">
<header class="report-hero panel">
<div class="hero-topline">
<span>ipsw</span>
<span>Diff Report</span>
</div>
<div class="hero-grid">
<section>
<h1 class="hero-title">{{ .Title }}</h1>
</section>
<section class="hero-meta">
<div class="meta-card">
<span class="meta-label">Old Input</span>
<span class="meta-value">{{ .OldInput }}</span>
{{- if .OldVersion }}<span class="meta-note">{{ .OldVersion }} &middot; {{ .OldBuild }}</span>{{ end }}
</div>
<div class="meta-card">
<span class="meta-label">New Input</span>
<span class="meta-value">{{ .NewInput }}</span>
{{- if .NewVersion }}<span class="meta-note">{{ .NewVersion }} &middot; {{ .NewBuild }}</span>{{ end }}
</div>
</section>
</div>
</header>
<div class="report-layout">
<aside class="report-sidebar panel">
<h2 class="sidebar-title">Contents</h2>
<nav class="toc">
<ul>
<li><a href="#inputs">Inputs</a></li>
<li><a href="#kernel">Kernel</a>
{{- if .HasKernelVersion }}<ul><li><a href="#kernel-version">Version</a></li></ul>{{ end }}
</li>
{{- if .Kexts }}
<li><a href="#kexts">Kexts</a>
<ul>
{{- if .Kexts.New }}<li><a href="#kexts-new">New ({{ len .Kexts.New }})</a></li>{{ end }}
{{- if .Kexts.Removed }}<li><a href="#kexts-removed">Removed ({{ len .Kexts.Removed }})</a></li>{{ end }}
{{- if .Kexts.Updated }}<li><a href="#kexts-updated">Updated ({{ len .Kexts.Updated }})</a></li>{{ end }}
</ul>
</li>
{{- end }}
{{- if .Machos }}
<li><a href="#machos">MachOs</a>
<ul>
{{- range .Machos }}
<li><a href="#machos-{{ .Name | slug }}">{{ .Name }}</a></li>
{{- end }}
</ul>
</li>
{{- end }}
{{- if .Ents }}<li><a href="#entitlements">Entitlements</a></li>{{ end }}
{{- if .Firmwares }}
<li><a href="#firmwares">Firmwares</a>
<ul>
{{- if .Firmwares.New }}<li><a href="#fw-new">New ({{ len .Firmwares.New }})</a></li>{{ end }}
{{- if .Firmwares.Removed }}<li><a href="#fw-removed">Removed ({{ len .Firmwares.Removed }})</a></li>{{ end }}
{{- if .Firmwares.Updated }}<li><a href="#fw-updated">Updated ({{ len .Firmwares.Updated }})</a></li>{{ end }}
</ul>
</li>
{{- end }}
{{- if .IBoot }}
<li><a href="#iboot">iBoot</a>
<ul>
{{- if .IBoot.New }}<li><a href="#iboot-new">New ({{ len .IBoot.New }})</a></li>{{ end }}
{{- if .IBoot.Removed }}<li><a href="#iboot-removed">Removed ({{ len .IBoot.Removed }})</a></li>{{ end }}
</ul>
</li>
{{- end }}
{{- if not .LaunchdFragment.Empty }}<li><a href="#launchd">launchd Config</a></li>{{ end }}
{{- if .Sandbox }}<li><a href="#sandbox-profiles">Sandbox Profiles</a></li>{{ end }}
<li><a href="#dsc">DSC</a>
<ul>
{{- if .OldWebkit }}<li><a href="#webkit">WebKit</a></li>{{ end }}
{{- if .Dylibs }}
<li><a href="#dylibs">Dylibs</a>
<ul>
{{- if .Dylibs.New }}<li><a href="#dylibs-new">New ({{ len .Dylibs.New }})</a></li>{{ end }}
{{- if .Dylibs.Removed }}<li><a href="#dylibs-removed">Removed ({{ len .Dylibs.Removed }})</a></li>{{ end }}
{{- if .Dylibs.Updated }}<li><a href="#dylibs-updated">Updated ({{ len .Dylibs.Updated }})</a></li>{{ end }}
</ul>
</li>
{{- end }}
</ul>
</li>
{{- if .Files }}
<li><a href="#files">Files</a>
<ul>
{{- if .Files.New }}<li><a href="#files-new">New ({{ len .Files.New }})</a></li>{{ end }}
{{- if .Files.Removed }}<li><a href="#files-removed">Removed ({{ len .Files.Removed }})</a></li>{{ end }}
</ul>
</li>
{{- end }}
{{- if .Localizations }}
<li><a href="#localizations">Localizations</a>
<ul>
{{- range .Localizations }}
<li><a href="#localizations-{{ .Name | slug }}">{{ .Name }}</a></li>
{{- end }}
</ul>
</li>
{{- end }}
{{- if .Features }}
<li><a href="#feature-flags">Feature Flags</a>
<ul>
{{- range .Features }}
<li><a href="#features-{{ .Name | slug }}">{{ .Name }}</a></li>
{{- end }}
</ul>
</li>
{{- end }}
</ul>
</nav>
</aside>
<main class="report-main panel">
<article class="rc">
<h2 id="inputs">Inputs</h2>
<ul>
<li><code>{{ .OldInput }}</code></li>
<li><code>{{ .NewInput }}</code></li>
</ul>
<h2 id="kernel">Kernel</h2>
{{- if .HasKernelVersion }}
<h3 id="kernel-version">Version</h3>
<table>
<caption>Kernel version comparison across the old and new inputs.</caption>
<thead><tr><th>iOS</th><th>Version</th><th>Build</th><th>Date</th></tr></thead>
<tbody>
<tr><td>{{ .OldVersion }} <em>({{ .OldBuild }})</em></td><td>{{ .OldKernelDarwin }}</td><td>{{ .OldKernelXNU }}</td><td>{{ .OldKernelDate }}</td></tr>
<tr><td>{{ .NewVersion }} <em>({{ .NewBuild }})</em></td><td>{{ .NewKernelDarwin }}</td><td>{{ .NewKernelXNU }}</td><td>{{ .NewKernelDate }}</td></tr>
</tbody>
</table>
{{- end }}
{{- if not .KextsFragment.Empty }}{{ .KextsFragment.Body }}{{- end }}
{{- if not .KDKsFragment.Empty }}{{ .KDKsFragment.Body }}{{- end }}
{{- if not .MachosFragment.Empty }}
<h2 id="{{ .MachosFragment.Anchor }}">{{ .MachosFragment.Heading }}</h2>{{ .MachosFragment.Body }}
{{- end }}
{{- if not .EntsFragment.Empty }}
<h2 id="{{ .EntsFragment.Anchor }}">{{ .EntsFragment.Heading }}</h2>{{ .EntsFragment.Body }}
{{- end }}
{{- if not .FirmwaresFragment.Empty }}{{ .FirmwaresFragment.Body }}{{- end }}
{{- if not .IBootFragment.Empty }}{{ .IBootFragment.Body }}{{- end }}
{{- if not .LaunchdFragment.Empty }}
<h2 id="{{ .LaunchdFragment.Anchor }}">{{ .LaunchdFragment.Heading }}</h2>{{ .LaunchdFragment.Body }}
{{- end }}
{{- if not .SandboxFragment.Empty }}{{ .SandboxFragment.Body }}{{- end }}
<h2 id="dsc">DSC</h2>
{{- if .OldWebkit }}
<h3 id="webkit">WebKit</h3>
<table>
<caption>WebKit version comparison across the old and new inputs.</caption>
<thead><tr><th>iOS</th><th>Version</th></tr></thead>
<tbody>
<tr><td>{{ .OldVersion }} <em>({{ .OldBuild }})</em></td><td>{{ .OldWebkit }}</td></tr>
<tr><td>{{ .NewVersion }} <em>({{ .NewBuild }})</em></td><td>{{ .NewWebkit }}</td></tr>
</tbody>
</table>
{{- end }}
{{- if .Dylibs }}
<h3 id="dylibs">Dylibs</h3>
{{- template "machoDiffSection" dict "Prefix" "dylibs" "Diff" .Dylibs }}
{{- end }}
{{- if not .FilesFragment.Empty }}
<h2 id="{{ .FilesFragment.Anchor }}">{{ .FilesFragment.Heading }}</h2>{{ .FilesFragment.Body }}
{{- end }}
{{- if not .LocalizationsFragment.Empty }}
<h2 id="{{ .LocalizationsFragment.Anchor }}">{{ .LocalizationsFragment.Heading }}</h2>{{ .LocalizationsFragment.Body }}
{{- end }}
{{- if not .FeaturesFragment.Empty }}
<h2 id="{{ .FeaturesFragment.Anchor }}">{{ .FeaturesFragment.Heading }}</h2>{{ .FeaturesFragment.Body }}
{{- end }}
</article>
</main>
</div>
</div>
<script>
(function(){
var links=document.querySelectorAll('.toc a[href^="#"]');
if(!links.length)return;
var ids=[];links.forEach(function(a){ids.push(a.getAttribute('href').slice(1));});
var io=new IntersectionObserver(function(entries){
entries.forEach(function(e){
if(e.isIntersecting){
links.forEach(function(a){a.classList.remove('active');});
var match=document.querySelector('.toc a[href="#'+e.target.id+'"]');
if(match)match.classList.add('active');
}
});
},{rootMargin:'-20% 0px -60% 0px'});
ids.forEach(function(id){var el=document.getElementById(id);if(el)io.observe(el);});
})();
</script>
</body>
</html>
{{ define "machoDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<ul>{{ range .Diff.New }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
<summary>View Updated ({{ len .Diff.Updated }})</summary>
{{- range .Diff.Updated }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
<pre><code>{{ .Diff }}</code></pre>
</div>
{{- end }}
</details>
{{- end }}
{{ end }}
{{ define "plistDiffSection" }}
{{- if .Diff.New }}
<h4 id="{{ .Prefix }}-new">New</h4>
<details>
<summary>View New ({{ len .Diff.New }})</summary>
{{- range .Diff.New }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
{{ .Content }}
</div>
{{- end }}
</details>
{{- end }}
{{- if .Diff.Removed }}
<h4 id="{{ .Prefix }}-removed">Removed</h4>
<ul>{{ range .Diff.Removed }}<li><code>{{ . }}</code></li>{{ end }}</ul>
{{- end }}
{{- if .Diff.Updated }}
<h4 id="{{ .Prefix }}-updated">Updated</h4>
<details>
<summary>View Updated ({{ len .Diff.Updated }})</summary>
{{- range .Diff.Updated }}
<div class="diff-entry">
<h5>{{ .Name }}</h5>
<div class="diff-entry-path"><code>{{ .Path }}</code></div>
{{ .Content }}
</div>
{{- end }}
</details>
{{- end }}
{{ end }}
`
func renderCodeBlock(raw string) template.HTML {
if raw == "" {
return ""
}
var buf strings.Builder
buf.WriteString("<pre><code>")
buf.WriteString(html.EscapeString(raw))
buf.WriteString("</code></pre>")
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(`<span class="diff-add">`)
buf.WriteString(escaped)
buf.WriteString("</span>")
case strings.HasPrefix(line, "-"):
buf.WriteString(`<span class="diff-del">`)
buf.WriteString(escaped)
buf.WriteString("</span>")
case strings.HasPrefix(line, "~"):
buf.WriteString(`<span class="diff-mod">`)
buf.WriteString(escaped)
buf.WriteString("</span>")
default:
buf.WriteString(escaped)
}
}
return template.HTML(buf.String())
}
func renderHighlightedDiffBlock(raw string) string {
if raw == "" {
return ""
}
var buf strings.Builder
buf.WriteString("<pre><code>")
buf.WriteString(string(highlightDiff(raw)))
buf.WriteString("</code></pre>")
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 <h2 id="...">. These IDs
// must not change without coordinating with downstream link consumers.
//
// Top-level task fragments (kexts, kdks, firmwares, iboot, sandbox)
// embed their own `<hN id="...">` 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)
}