feat(diff): add sandbox profile diffing behind sandbox build tag

This commit is contained in:
blacktop
2026-05-03 12:44:18 -06:00
parent c9a847a389
commit fc41188d93
15 changed files with 606 additions and 0 deletions
+5
View File
@@ -50,6 +50,7 @@ func init() {
diffCmd.Flags().Bool("low-memory", false, "Use disk caching to reduce RAM usage")
diffCmd.Flags().StringSlice("allow-list", []string{}, "Filter MachO sections to diff (e.g. __TEXT.__text)")
diffCmd.Flags().StringSlice("block-list", []string{}, "Remove MachO sections to diff (e.g. __TEXT.__info_plist)")
registerDiffSandboxFlags(diffCmd)
diffCmd.Flags().StringP("signatures", "s", "", "Path to symbolicator signatures folder")
diffCmd.MarkFlagDirname("signatures")
diffCmd.Flags().StringP("output", "o", "", "Folder to save diff output")
@@ -140,6 +141,9 @@ var diffCmd = &cobra.Command{
if len(viper.GetStringSlice("diff.kdk")) > 0 && len(viper.GetStringSlice("diff.kdk")) != 2 {
return fmt.Errorf("you must specify two KDKs to diff; example: --kdk <KDK1> --kdk <KDK2>")
}
if err := diffSandboxPreflight(); err != nil {
return err
}
d := diff.New(&diff.Config{
Title: viper.GetString("diff.title"),
@@ -150,6 +154,7 @@ var diffCmd = &cobra.Command{
Firmware: viper.GetBool("diff.fw"),
Features: viper.GetBool("diff.feat"),
Files: viper.GetBool("diff.files"),
Sandbox: diffSandboxEnabled(),
CStrings: viper.GetBool("diff.strs"),
FuncStarts: viper.GetBool("diff.starts"),
Entitlements: viper.GetBool("diff.ent"),
+21
View File
@@ -0,0 +1,21 @@
//go:build sandbox
package cmd
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func registerDiffSandboxFlags(cmd *cobra.Command) {
cmd.Flags().Bool("sandbox", false, "Diff compiled sandbox profiles")
viper.BindPFlag("diff.sandbox", cmd.Flags().Lookup("sandbox"))
}
func diffSandboxEnabled() bool {
return viper.GetBool("diff.sandbox")
}
func diffSandboxPreflight() error {
return nil
}
+26
View File
@@ -0,0 +1,26 @@
//go:build !sandbox
package cmd
import (
"github.com/blacktop/ipsw/internal/diff"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func registerDiffSandboxFlags(cmd *cobra.Command) {
cmd.Flags().Bool("sandbox", false, "Diff compiled sandbox profiles")
cmd.Flags().Lookup("sandbox").Hidden = true
viper.BindPFlag("diff.sandbox", cmd.Flags().Lookup("sandbox"))
}
func diffSandboxEnabled() bool {
return viper.GetBool("diff.sandbox")
}
func diffSandboxPreflight() error {
if !viper.GetBool("diff.sandbox") {
return nil
}
return diff.ErrSandboxDiffUnavailable
}
+10
View File
@@ -77,6 +77,7 @@ type Config struct {
Firmware bool
Features bool
Files bool
Sandbox bool
CStrings bool
FuncStarts bool
Entitlements bool
@@ -137,6 +138,7 @@ type Diff struct {
Firmwares *mcmd.MachoDiff `json:"firmwares,omitempty"`
IBoot *IBootDiff `json:"iboot,omitempty"`
Launchd string `json:"launchd,omitempty"`
Sandbox string `json:"sandbox,omitempty"`
Features *PlistDiff `json:"features,omitempty"`
Files *FileDiff `json:"files,omitempty"`
tmpDir string `json:"-"`
@@ -428,6 +430,14 @@ func (d *Diff) Diff() (err error) {
}
}
if d.conf.Sandbox && !directoryMode {
log.Info("Diffing Sandbox Profiles")
d.Sandbox, err = d.parseSandboxProfiles()
if err != nil {
log.WithError(err).Error("failed to diff sandbox profiles")
}
}
log.Info("Diffing DYLD_SHARED_CACHES")
if !directoryMode {
if err := d.mountSystemOsDMGs(); err != nil {
+3
View File
@@ -70,6 +70,9 @@ func unsupportedFlagsForDirectoryMode(conf *Config) []string {
if conf.LowMemory {
unsupported = append(unsupported, "--low-memory")
}
if conf.Sandbox {
unsupported = append(unsupported, "--sandbox")
}
return unsupported
}
+16
View File
@@ -54,6 +54,9 @@ const diffMarkdownTemplate = `
{{- end }}
{{- if .Ents }}
- [🔑 Entitlements](#entitlements)
{{- end }}
{{- if .Sandbox }}
- [Sandbox Profiles](#sandbox-profiles)
{{- end }}
- [DSC](#dsc)
- [WebKit](#webkit)
@@ -161,6 +164,11 @@ const diffMarkdownTemplate = `
</details>
{{ end -}}
{{ if .Sandbox }}
## Sandbox Profiles
{{ .Sandbox | noescape }}
{{ end -}}
{{- if .Firmwares }}
## Firmwares
{{ if .Firmwares.New }}
@@ -312,6 +320,7 @@ type diffHTMLPageData struct {
KDKs template.HTML
Machos *htmlMachoDiff
Ents template.HTML
Sandbox template.HTML
Firmwares *htmlMachoDiff
IBoot *htmlIBootDiff
Launchd template.HTML
@@ -735,6 +744,7 @@ const diffHTMLPageTemplate = `<!DOCTYPE html>
</li>
{{- end }}
{{- if .Launchd }}<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 }}
@@ -862,6 +872,11 @@ const diffHTMLPageTemplate = `<!DOCTYPE html>
{{ .Launchd }}
{{- end }}
{{- if .Sandbox }}
<h2 id="sandbox-profiles">Sandbox Profiles</h2>
{{ .Sandbox }}
{{- end }}
<h2 id="dsc">DSC</h2>
{{- if .OldWebkit }}
<h3 id="webkit">WebKit</h3>
@@ -1261,6 +1276,7 @@ func (d *Diff) renderHTML() (string, error) {
Features: convertPlistDiff(d.Features),
Files: convertFileDiff(d.Files),
Ents: renderMarkdownFragment(d.Ents),
Sandbox: renderMarkdownFragment(d.Sandbox),
KDKs: renderMarkdownFragment(d.KDKs),
Launchd: renderMarkdownFragment(d.Launchd),
}
+86
View File
@@ -1,6 +1,7 @@
package diff
import (
"encoding/json"
"os"
"strings"
"testing"
@@ -163,6 +164,91 @@ func TestRenderHTMLIncludesIBootFilesAndFeatureFlags(t *testing.T) {
}
}
func TestRenderHTMLIncludesSandboxProfiles(t *testing.T) {
d := newHTMLTestDiff("Test Diff")
d.Sandbox = "### Collection\n\n#### Changed (1)\n\n##### locationd\n\n```diff\n-(deny default)\n+(allow default)\n```\n"
rendered := mustRenderHTML(t, d)
for _, needle := range []string{
`href="#sandbox-profiles"`,
`id="sandbox-profiles"`,
`locationd`,
`class="diff-add"`,
`class="diff-del"`,
} {
if !strings.Contains(rendered, needle) {
t.Fatalf("rendered HTML missing %q", needle)
}
}
}
func TestStringIncludesSandboxProfiles(t *testing.T) {
d := newHTMLTestDiff("Test Diff")
d.Sandbox = "### Collection\n\n#### New (1)\n\n##### locationd\n\n```scheme\n(version 1)\n```\n"
rendered := d.String()
for _, needle := range []string{
"## Sandbox Profiles",
"### Collection",
"##### locationd",
} {
if !strings.Contains(rendered, needle) {
t.Fatalf("rendered Markdown missing %q", needle)
}
}
}
func TestJSONIncludesSandboxProfiles(t *testing.T) {
d := newHTMLTestDiff("Test Diff")
d.Sandbox = "### Collection\n\n#### Changed (1)\n"
data, err := json.Marshal(d)
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if !strings.Contains(string(data), `"sandbox"`) {
t.Fatalf("JSON missing sandbox field: %s", data)
}
}
func TestRenderSandboxProfileDiffMarkdown(t *testing.T) {
oldDocs := sandboxProfileDocuments{
sandboxDiffSourceCollection: {
"locationd": "(version 1)\n(deny default)\n",
"removed": "(version 1)\n",
},
}
newDocs := sandboxProfileDocuments{
sandboxDiffSourceCollection: {
"locationd": "(version 1)\n(allow default)\n",
"added": "(version 1)\n(deny default)\n",
},
}
rendered, err := renderSandboxProfileDiffMarkdown(oldDocs, newDocs)
if err != nil {
t.Fatalf("renderSandboxProfileDiffMarkdown returned error: %v", err)
}
for _, needle := range []string{
"### Collection",
"#### New (1)",
"##### added",
"#### Removed (1)",
"##### removed",
"#### Changed (1)",
"##### locationd",
"+(allow default)",
"-(deny default)",
} {
if !strings.Contains(rendered, needle) {
t.Fatalf("rendered sandbox diff missing %q:\n%s", needle, rendered)
}
}
}
func TestTitleToFilenameSanitizesDiffReportName(t *testing.T) {
d := New(&Config{
Title: "23D8133__iPhone17,1 .vs 23D771330a__iPhone17,1",
+15
View File
@@ -219,6 +219,21 @@ func (d *Diff) Markdown() error {
out.WriteString(fmt.Sprintf("- [%s](%s)\n\n", "Entitlements DIFF", "Entitlements.md"))
}
// SUB-SECTION: Sandbox
if len(d.Sandbox) > 0 {
out.WriteString("### Sandbox Profiles\n\n")
fname := filepath.Join(d.conf.Output, "Sandbox.md")
log.Debugf("Creating diff Sandbox Markdown: %s", fname)
f, err := os.Create(fname)
if err != nil {
return fmt.Errorf("failed to create diff Sandbox Markdown: %w", err)
}
fmt.Fprintf(f, "## Sandbox Profiles\n\n")
fmt.Fprintf(f, "%s", d.Sandbox)
f.Close()
out.WriteString(fmt.Sprintf("- [%s](%s)\n\n", "Sandbox Profiles DIFF", "Sandbox.md"))
}
// SECTION: Firmware
if d.Firmwares != nil && (len(d.Firmwares.New) > 0 || len(d.Firmwares.Removed) > 0 || len(d.Firmwares.Updated) > 0) {
out.WriteString("## Firmware\n\n")
+6
View File
@@ -36,6 +36,7 @@ func TestUnsupportedFlagsForOTAMode(t *testing.T) {
Features: true,
Entitlements: true,
Files: true,
Sandbox: true,
CStrings: true,
},
expected: nil,
@@ -83,6 +84,11 @@ func TestUnsupportedFlagsForDirectoryMode(t *testing.T) {
conf: Config{LowMemory: true},
expected: []string{"--low-memory"},
},
{
name: "sandbox blocked",
conf: Config{Sandbox: true},
expected: []string{"--sandbox"},
},
{
name: "supported flags not blocked",
conf: Config{
+195
View File
@@ -0,0 +1,195 @@
//go:build sandbox
package diff
import (
"errors"
"fmt"
"strings"
"github.com/apex/log"
"github.com/blacktop/go-macho"
"github.com/blacktop/go-macho/pkg/fixupchains"
"github.com/blacktop/go-macho/types"
"github.com/blacktop/ipsw/pkg/sandbox"
"github.com/blacktop/ipsw/pkg/sandbox/normalize"
)
const (
maxSandboxDiffNormalizedNodes = 1000
maxSandboxDiffOutputBytes = 32 << 20
)
func (d *Diff) parseSandboxProfiles() (string, error) {
oldDocs, err := collectSandboxProfileDocuments(&d.Old)
if err != nil {
return "", fmt.Errorf("old sandbox profiles: %w", err)
}
newDocs, err := collectSandboxProfileDocuments(&d.New)
if err != nil {
return "", fmt.Errorf("new sandbox profiles: %w", err)
}
if len(oldDocs) == 0 && len(newDocs) == 0 {
return "", nil
}
return renderSandboxProfileDiffMarkdown(oldDocs, newDocs)
}
func collectSandboxProfileDocuments(ctx *Context) (sandboxProfileDocuments, error) {
if ctx.Kernel.Path == "" {
return nil, fmt.Errorf("kernelcache path is empty")
}
kernel, err := macho.Open(ctx.Kernel.Path)
if err != nil {
return nil, fmt.Errorf("failed to open kernelcache: %w", err)
}
defer kernel.Close()
var fixups map[uint64]uint64
if kernel.FileTOC.FileHeader.Type == types.MH_FILESET {
fixups, err = buildSandboxDiffFixupMap(kernel)
if err != nil {
return nil, fmt.Errorf("failed to build fileset fixup map: %w", err)
}
}
out := make(sandboxProfileDocuments)
for _, source := range sandboxDiffSourceOrder {
profiles, err := renderSandboxSourceProfiles(kernel, fixups, source)
if err != nil {
if isSandboxSourceUnavailable(err) {
log.WithError(err).Debugf("skipping unavailable %s sandbox source", source)
continue
}
return nil, fmt.Errorf("%s: %w", source, err)
}
if len(profiles) > 0 {
out[source] = profiles
}
}
return out, nil
}
func renderSandboxSourceProfiles(kernel *macho.File, fixups map[uint64]uint64, source string) (map[string]string, error) {
conf := &sandbox.Config{Kernel: kernel}
if fixups != nil {
conf.Fixups = fixups
}
sbObj, err := sandbox.NewSandbox(conf)
if err != nil {
return nil, fmt.Errorf("failed to create sandbox parser: %w", err)
}
switch source {
case sandboxDiffSourceCollection:
if _, err := sbObj.GetCollectionData(); err != nil {
return nil, fmt.Errorf("failed to load collection data: %w", err)
}
if err := sbObj.ParseSandboxCollection(); err != nil {
return nil, fmt.Errorf("failed to parse collection data: %w", err)
}
case sandboxDiffSourceProtobox:
if _, err := sbObj.GetProtoboxCollectionData(); err != nil {
return nil, fmt.Errorf("failed to load protobox data: %w", err)
}
if err := sbObj.ParseProtoboxCollection(); err != nil {
return nil, fmt.Errorf("failed to parse protobox data: %w", err)
}
case sandboxDiffSourceProfile:
if _, err := sbObj.GetPlatformProfileData(); err != nil {
return nil, fmt.Errorf("failed to load platform profile data: %w", err)
}
if err := sbObj.ParseSandboxProfile(); err != nil {
return nil, fmt.Errorf("failed to parse platform profile data: %w", err)
}
default:
return nil, fmt.Errorf("unsupported sandbox source %q", source)
}
return renderSandboxProfiles(sbObj, source)
}
func renderSandboxProfiles(sbObj *sandbox.Sandbox, source string) (map[string]string, error) {
out := make(map[string]string, len(sbObj.Profiles))
limit := maxSandboxDiffNormalizedNodes
if source == sandboxDiffSourceProfile {
// 0 disables the per-operation node budget. The platform profile is
// one large standalone document, unlike collection profile entries.
limit = 0
}
for idx, prof := range sbObj.Profiles {
name := sandboxProfileDocumentName(source, prof, idx)
formatted, diags, err := normalize.FormatCompilerSafeProfileWithDiagnostics(
sbObj,
prof,
limit,
maxSandboxDiffOutputBytes,
)
if err != nil {
return nil, fmt.Errorf("failed to render %s: %w", name, err)
}
if strings.TrimSpace(formatted) == "" {
continue
}
if len(diags) > 0 {
log.Warnf("%s/%s: %d sandbox operation(s) skipped due to budget limits",
source, name, len(diags))
}
if _, exists := out[name]; exists {
name = uniqueSandboxProfileDocumentName(out, name, idx)
}
out[name] = formatted
}
return out, nil
}
func sandboxProfileDocumentName(source string, prof sandbox.Profile, idx int) string {
if prof.Name != "" {
return prof.Name
}
if source == sandboxDiffSourceProfile {
return "platform"
}
return fmt.Sprintf("profile_%03d", idx)
}
func uniqueSandboxProfileDocumentName(existing map[string]string, name string, idx int) string {
candidate := fmt.Sprintf("%s#%d", name, idx)
for suffix := 2; ; suffix++ {
if _, exists := existing[candidate]; !exists {
return candidate
}
candidate = fmt.Sprintf("%s#%d.%d", name, idx, suffix)
}
}
func isSandboxSourceUnavailable(err error) bool {
return errors.Is(err, sandbox.ErrSandboxSourceUnavailable)
}
func buildSandboxDiffFixupMap(kernel *macho.File) (map[uint64]uint64, error) {
fixups := make(map[uint64]uint64)
if !kernel.HasFixups() {
return fixups, nil
}
dcf, err := kernel.DyldChainedFixups()
if err != nil {
return nil, err
}
for _, start := range dcf.Starts {
if start.PageStarts == nil {
continue
}
for _, fixup := range start.Fixups {
if rebase, ok := fixup.(fixupchains.Rebase); ok {
fixups[rebase.Raw()] = uint64(rebase.Offset()) + kernel.GetBaseAddress()
}
}
}
return fixups, nil
}
+162
View File
@@ -0,0 +1,162 @@
package diff
import (
"fmt"
"slices"
"strings"
"github.com/blacktop/ipsw/internal/utils"
)
const (
sandboxDiffSourceCollection = "collection"
sandboxDiffSourceProtobox = "protobox"
sandboxDiffSourceProfile = "profile"
)
var sandboxDiffSourceOrder = []string{
sandboxDiffSourceCollection,
sandboxDiffSourceProtobox,
sandboxDiffSourceProfile,
}
type sandboxProfileDocuments map[string]map[string]string
func renderSandboxProfileDiffMarkdown(oldDocs, newDocs sandboxProfileDocuments) (string, error) {
var b strings.Builder
for _, source := range sandboxProfileSourceNames(oldDocs, newDocs) {
oldProfiles := oldDocs[source]
newProfiles := newDocs[source]
section, err := renderSandboxSourceDiffMarkdown(source, oldProfiles, newProfiles)
if err != nil {
return "", err
}
if section == "" {
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(section)
}
return b.String(), nil
}
func sandboxProfileSourceNames(oldDocs, newDocs sandboxProfileDocuments) []string {
seen := make(map[string]bool)
var names []string
for _, source := range sandboxDiffSourceOrder {
if len(oldDocs[source]) > 0 || len(newDocs[source]) > 0 {
names = append(names, source)
seen[source] = true
}
}
var extras []string
for source := range oldDocs {
if !seen[source] {
extras = append(extras, source)
seen[source] = true
}
}
for source := range newDocs {
if !seen[source] {
extras = append(extras, source)
}
}
slices.Sort(extras)
return append(names, extras...)
}
func renderSandboxSourceDiffMarkdown(source string, oldProfiles, newProfiles map[string]string) (string, error) {
var removed []string
var added []string
var changed []string
for name := range oldProfiles {
if _, ok := newProfiles[name]; !ok {
removed = append(removed, name)
}
}
for name := range newProfiles {
oldText, ok := oldProfiles[name]
newText := newProfiles[name]
switch {
case !ok:
added = append(added, name)
case oldText != newText:
changed = append(changed, name)
}
}
if len(removed) == 0 && len(added) == 0 && len(changed) == 0 {
return "", nil
}
slices.Sort(removed)
slices.Sort(added)
slices.Sort(changed)
var b strings.Builder
fmt.Fprintf(&b, "### %s\n\n", sandboxSourceTitle(source))
if len(added) > 0 {
fmt.Fprintf(&b, "#### New (%d)\n\n", len(added))
for _, name := range added {
writeSandboxProfileDocument(&b, name, newProfiles[name])
}
}
if len(removed) > 0 {
fmt.Fprintf(&b, "#### Removed (%d)\n\n", len(removed))
for _, name := range removed {
writeSandboxProfileDocument(&b, name, oldProfiles[name])
}
}
if len(changed) > 0 {
fmt.Fprintf(&b, "#### Changed (%d)\n\n", len(changed))
for _, name := range changed {
out, err := utils.GitDiff(
ensureTrailingNewline(oldProfiles[name]),
ensureTrailingNewline(newProfiles[name]),
&utils.GitDiffConfig{Color: false, Tool: "git"},
)
if err != nil {
return "", fmt.Errorf("failed to diff sandbox profile %s: %w", name, err)
}
if strings.TrimSpace(out) == "" {
continue
}
fmt.Fprintf(&b, "##### %s\n\n```diff\n%s\n```\n\n", name, strings.TrimRight(out, "\n"))
}
}
return strings.TrimRight(b.String(), "\n") + "\n", nil
}
func sandboxSourceTitle(source string) string {
switch source {
case sandboxDiffSourceCollection:
return "Collection"
case sandboxDiffSourceProtobox:
return "Protobox/Autobox"
case sandboxDiffSourceProfile:
return "Platform Profile"
default:
return source
}
}
func writeSandboxProfileDocument(b *strings.Builder, name, body string) {
fmt.Fprintf(b, "##### %s\n\n```scheme\n%s\n```\n\n", name, strings.TrimRight(body, "\n"))
}
func ensureTrailingNewline(value string) string {
if strings.HasSuffix(value, "\n") {
return value
}
return value + "\n"
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !sandbox
package diff
func (d *Diff) parseSandboxProfiles() (string, error) {
return "", ErrSandboxDiffUnavailable
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !sandbox
package diff
import (
"errors"
"testing"
)
func TestParseSandboxProfilesStubReturnsUnavailable(t *testing.T) {
_, err := (&Diff{}).parseSandboxProfiles()
if !errors.Is(err, ErrSandboxDiffUnavailable) {
t.Fatalf("parseSandboxProfiles() error = %v, want %v", err, ErrSandboxDiffUnavailable)
}
}
+5
View File
@@ -0,0 +1,5 @@
package diff
import "errors"
var ErrSandboxDiffUnavailable = errors.New("sandbox diff support is not built; rebuild with -tags sandbox")
+34
View File
@@ -0,0 +1,34 @@
//go:build sandbox
package diff
import (
"fmt"
"testing"
"github.com/blacktop/ipsw/pkg/sandbox"
"github.com/blacktop/ipsw/pkg/sandbox/normalize"
)
func TestIsSandboxSourceUnavailableUsesSentinel(t *testing.T) {
err := fmt.Errorf("failed to load collection data: %w", sandbox.ErrSandboxSourceUnavailable)
if !isSandboxSourceUnavailable(err) {
t.Fatal("expected sandbox source unavailable sentinel to be skipped")
}
if isSandboxSourceUnavailable(normalize.ErrFormattedOutputTooLarge) {
t.Fatal("formatter budget errors must not be treated as unavailable sources")
}
}
func TestUniqueSandboxProfileDocumentNameAvoidsExistingSuffix(t *testing.T) {
existing := map[string]string{
"profile": "first",
"profile#1": "second",
}
got := uniqueSandboxProfileDocumentName(existing, "profile", 1)
if got != "profile#1.2" {
t.Fatalf("uniqueSandboxProfileDocumentName() = %q, want %q", got, "profile#1.2")
}
}