From fc41188d93c2c5a90ed5f5d6c7c73bf86da84edf Mon Sep 17 00:00:00 2001 From: blacktop Date: Sun, 3 May 2026 12:44:18 -0600 Subject: [PATCH] feat(diff): add sandbox profile diffing behind `sandbox` build tag --- cmd/ipsw/cmd/diff.go | 5 + cmd/ipsw/cmd/diff_sandbox.go | 21 +++ cmd/ipsw/cmd/diff_sandbox_stub.go | 26 ++++ internal/diff/diff.go | 10 ++ internal/diff/directory_inputs.go | 3 + internal/diff/format.go | 16 +++ internal/diff/format_test.go | 86 ++++++++++++ internal/diff/md.go | 15 +++ internal/diff/ota_inputs_test.go | 6 + internal/diff/sandbox.go | 195 +++++++++++++++++++++++++++ internal/diff/sandbox_diff_format.go | 162 ++++++++++++++++++++++ internal/diff/sandbox_stub.go | 7 + internal/diff/sandbox_stub_test.go | 15 +++ internal/diff/sandbox_support.go | 5 + internal/diff/sandbox_test.go | 34 +++++ 15 files changed, 606 insertions(+) create mode 100644 cmd/ipsw/cmd/diff_sandbox.go create mode 100644 cmd/ipsw/cmd/diff_sandbox_stub.go create mode 100644 internal/diff/sandbox.go create mode 100644 internal/diff/sandbox_diff_format.go create mode 100644 internal/diff/sandbox_stub.go create mode 100644 internal/diff/sandbox_stub_test.go create mode 100644 internal/diff/sandbox_support.go create mode 100644 internal/diff/sandbox_test.go diff --git a/cmd/ipsw/cmd/diff.go b/cmd/ipsw/cmd/diff.go index 1fa621401..8409cd954 100644 --- a/cmd/ipsw/cmd/diff.go +++ b/cmd/ipsw/cmd/diff.go @@ -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 --kdk ") } + 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"), diff --git a/cmd/ipsw/cmd/diff_sandbox.go b/cmd/ipsw/cmd/diff_sandbox.go new file mode 100644 index 000000000..3a52d5332 --- /dev/null +++ b/cmd/ipsw/cmd/diff_sandbox.go @@ -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 +} diff --git a/cmd/ipsw/cmd/diff_sandbox_stub.go b/cmd/ipsw/cmd/diff_sandbox_stub.go new file mode 100644 index 000000000..a165a2563 --- /dev/null +++ b/cmd/ipsw/cmd/diff_sandbox_stub.go @@ -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 +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go index de7496f5f..f5d235189 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -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 { diff --git a/internal/diff/directory_inputs.go b/internal/diff/directory_inputs.go index 76ce847ff..59b27c346 100644 --- a/internal/diff/directory_inputs.go +++ b/internal/diff/directory_inputs.go @@ -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 } diff --git a/internal/diff/format.go b/internal/diff/format.go index 034e7dfb1..b41aec19c 100644 --- a/internal/diff/format.go +++ b/internal/diff/format.go @@ -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 = ` {{ 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 = ` {{- end }} {{- if .Launchd }}
  • launchd Config
  • {{ end }} + {{- if .Sandbox }}
  • Sandbox Profiles
  • {{ end }}
  • DSC
      {{- if .OldWebkit }}
    • WebKit
    • {{ end }} @@ -862,6 +872,11 @@ const diffHTMLPageTemplate = ` {{ .Launchd }} {{- end }} + {{- if .Sandbox }} +

      Sandbox Profiles

      + {{ .Sandbox }} + {{- end }} +

      DSC

      {{- if .OldWebkit }}

      WebKit

      @@ -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), } diff --git a/internal/diff/format_test.go b/internal/diff/format_test.go index b01b38ed2..c279b752a 100644 --- a/internal/diff/format_test.go +++ b/internal/diff/format_test.go @@ -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", diff --git a/internal/diff/md.go b/internal/diff/md.go index 056700f6c..e5929a6d9 100644 --- a/internal/diff/md.go +++ b/internal/diff/md.go @@ -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") diff --git a/internal/diff/ota_inputs_test.go b/internal/diff/ota_inputs_test.go index 87ffcebdb..805b43c24 100644 --- a/internal/diff/ota_inputs_test.go +++ b/internal/diff/ota_inputs_test.go @@ -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{ diff --git a/internal/diff/sandbox.go b/internal/diff/sandbox.go new file mode 100644 index 000000000..400060986 --- /dev/null +++ b/internal/diff/sandbox.go @@ -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 +} diff --git a/internal/diff/sandbox_diff_format.go b/internal/diff/sandbox_diff_format.go new file mode 100644 index 000000000..50bfc3bcd --- /dev/null +++ b/internal/diff/sandbox_diff_format.go @@ -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" +} diff --git a/internal/diff/sandbox_stub.go b/internal/diff/sandbox_stub.go new file mode 100644 index 000000000..f8cf8a084 --- /dev/null +++ b/internal/diff/sandbox_stub.go @@ -0,0 +1,7 @@ +//go:build !sandbox + +package diff + +func (d *Diff) parseSandboxProfiles() (string, error) { + return "", ErrSandboxDiffUnavailable +} diff --git a/internal/diff/sandbox_stub_test.go b/internal/diff/sandbox_stub_test.go new file mode 100644 index 000000000..d52c1ac64 --- /dev/null +++ b/internal/diff/sandbox_stub_test.go @@ -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) + } +} diff --git a/internal/diff/sandbox_support.go b/internal/diff/sandbox_support.go new file mode 100644 index 000000000..1aae69cbd --- /dev/null +++ b/internal/diff/sandbox_support.go @@ -0,0 +1,5 @@ +package diff + +import "errors" + +var ErrSandboxDiffUnavailable = errors.New("sandbox diff support is not built; rebuild with -tags sandbox") diff --git a/internal/diff/sandbox_test.go b/internal/diff/sandbox_test.go new file mode 100644 index 000000000..e813f8d4f --- /dev/null +++ b/internal/diff/sandbox_test.go @@ -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") + } +}